Retour sur le Forum PHP 2024
Découvrez un résumé concis des conférences qui nous ont le plus marqué lors du Forum PHP 2024 !
Sommaire
Je suis sûr que vous rencontrez tous cette problématique : vous êtes obligés d'utiliser les outils ERP de votre entreprise pour faire vos demandes de congés, faire vos comptes-rendus d'activité, vos notes de frais, etc... Et avouez le, ça vous emm$#de ! Pourquoi ? Car ces outils ne sont pas ergonomiques, pas compatibles sur mobile...
Nous avons pourtant une infinité de possibilités de simplifications, si on fait preuve d'un peu d'imagination :) !
Je vais vous présenter dans cet article une de ces idées qui me permet de simplifier le process de demande de congés.
Voilà plus d'éléments sur notre process.
Si on prend un peu de recul sur le process existant, on se rend compte que l'objectif est d'une part d'obtenir une validation, puis communiquer à tous les dates d'absences à venir. Mais en réalité, l'étape de validation n'est pas très utile car les congés sont quasiment systématiquement validés.
Il suffirait donc d'envoyer les dates de congés dans un outils qui communiquerait ensuite à tous, en considérant que la demande est validée par défaut. On pourrait gérer les cas de refus de congés manuellement en parallèle, si ça arrivait.
Quel est l'outil de communication interne le plus ergonomique ? Slack bien sûr !
L'idée serait donc de mettre en place un bot Slack qui :
Si une validation est vraiment nécessaire, on pourrait aussi imaginer que ce bot enverrait des demandes de validation, soit par Slack en utilisant les interactive message buttons, soit par email avec un lien de validation.
Vous l'avez compris, les possibilités sont multiples, mais concentrons nous ici sur le point 1. ci-dessus uniquement, le plus intéressant.
Pour mettre en place cette première étape du process, nous avons allons donc :
Commençons par la mise en place du bot Slack.
Il faut tout d'abord créer une app Slack.
Connectez vous donc à votre compte Slack relié à votre Workspace d'entreprise. Puis allez sur https://api.slack.com/apps et cliquez sur "Create New App".
Puis à vous de compléter les informations de votre app comme bon vous semble : nom, description, couleur et icône.
Vous pourrez ensuite accéder aux configurations suivantes depuis cet écran de "Basic Information" :
Il faut maintenant créer un utilisateur bot relié à cette app. Pour cela, rendez vous dans le menu de gauche "Bot Users" ou depuis les "Basic Information" > "Add features and functionality" > "Bots".
Il suffit ici de nommer le bot et de le rendre visible "online".
Ensuite allez dans le menu "Event Subscriptions", saisissez l'URL de votre futur webhook Symfony que nous implémenterons dans la dernière partie. Notez que tant que le webhook n'est pas créé et accessible par Slack, ce dernier ne pourra pas le vérifier et l'enregistrer, il faudra donc revenir plus tard à cette étape quand le webhook sera prêt.
Il faut également sélectionner l'event "message.im" pour signifier à Slack d'appeler le webhook précédent à chaque fois qu'un message privé est envoyé à notre bot.
Les appels faits vers ce webhook devront être sécurisés à l'aide d'un token qui sera utilisé dans la dernière partie : veuillez donc noter la valeur du "Verification Token" affichée sur la page "Basic Information".
Vous vous en doutez, les accès aux données Slack sont protégés. Il faut donc configurer notre bot pour que celui-ci ait accès à certains scopes de données avec son access token.
Cela se passe dans la partie "OAuth & Permissions".
Tout d'abord, vous pouvez noter la valeur d'"OAuth Access Token" qui apparaît sur cette page et que nous utiliserons plus tard.
Ensuite, voilà les scopes dont vous aurez forcément besoin, et donc à ajouter sur cette même page :
Pas forcément besoin d'ajouter plus de scopes pour le moment, et vous verrez avec l'expérience que Slack vous indique bien les scopes à autoriser si nécessaire quand vous appelez une méthode de l'API non autorisée.
Maintenant que notre bot Slack est prêt, nous avons besoin de configurer un agent DialogFlow pour qu'il nous aide à comprendre les messages envoyés par les utilisateurs.
Créez donc un compte, si vous n'en avez pas déjà un, et connectez vous sur la console DialogFlow. Puis créez un nouvel agent (bouton "Create New Agent") et sélectionnez la langue par défaut fr.
Les "intents" correspondent aux types de messages de l'utilisateur que nous avons envie de comprendre. Nous allons en configurer trois dans le cadre de cet article :
Nous allons lister dans la partie "User says" un maximum d'inputs utilisateurs qui pourraient être envoyés par les astronautes qui font leur demande de congés.
Pour chacun de ces inputs, nous sélectionnons les passages les plus intéressants, en jaune et orange sur l'image ci-dessus. Ces passages correspondent aux dates de congés qu'on doit reconnaître puis enregistrer.
Ces sélections sont associées à des paramètres que nous nommerons "startDate" et "endDate" et que nous typons en tant que "@sys.date" pour que Google reconnaisse automatiquement ces dates.
Enfin, nous pouvons configurer les réponses qui seront renvoyées par DialogFlow quand on lui enverra un message de ce type, s'il le reconnaît :
Nous avons deux types de réponses :
Quant à lui, il nous permettra de répondre poliment à l'astronaute qui nous dit bonjour. Mais pas de paramètre à configurer pour celui-ci.
Il nous permet de configurer des messages par défaut, quand le message de l'utilisateur n'est pas reconnu par les précédent intents.
Tout le code de l'application Symfony qui permet de communiquer avec Slack et DialogFlow est sur mon Github ici. Je vais détailler ici uniquement les parties les plus importantes.
Je vous recommande d'utiliser les options "autoconfigure" et "autowiring" pour gérer vos injections de dépendances en toute simplicité : voir app/config/services.yml.
Tout d'abord, il faut créer l'action avec une route qui doit correspondre à ce qui a été configuré dans la partie "Event Subscriptions" de l'app Slack.
Pour que Slack vérifie ce webhook, il faut non seulement vérifier le "Verification Token" envoyé dans la requête de Slack mais également retourner le "challenge" envoyé par Slack en cas de requête de type "url_verification".
Voilà donc le code à utiliser :
<?php
// src/AppBundle/Action/Webhook/SlackAction.php
namespace AppBundle\Action\Webhook;
// use statements...
final class SlackAction
{
// private properties and __construct...
/**
* @Route("/api/webhook/slack", defaults={"_format": "json"})
* @Method("POST")
*/
public function __invoke(Request $request): Response
{
$content = json_decode($request->getContent(), true);
// check "Verification Token"
if (!isset($content['token']) || $content['token'] !== $this->slackWebhookToken) {
throw new AccessDeniedHttpException('No token given or token is wrong.');
}
// return "challenge" to allow Slack to verify this route
if (isset($content['type']) && $content['type'] === 'url_verification') {
return new JsonResponse(['challenge' => $content['challenge']]);
}
// $content -> valid content
// call other services from here.
return new Response('', 204);
}
}
Ensuite, nous récupérons le "content" de la requête qui a cette forme :
{ ... "event": { "type": "message", "text": "Je veux poser des congés entre le 5 décembre et le 6 janvier", "user": "XXXXXX", "channel": "ZZZZZZ" }, ... }
Nous pouvons donc utiliser un service pour extraire les données qui nous intéressent :
Voir src/AppBundle/Slack/WebhookParser.php pour plus de détails.
Nous avons besoin des informations de l'astronaute qui a envoyé le message Slack pour être capable de créer un Member
dans notre application Symfony, qui sera relié à notre demande de congés.
Pour cela, nous allons appeler la méthode "users.info" de l'API Slack. Pour plus de détails, voir la doc de cette API ici.
Il nous faut donc un client Guzzle pour appeler l'API Slack :
// src/AppBundle/Slack/Client.php
namespace AppBundle\Slack;
// use statements...
class Client
{
private $client; // GuzzleHttp\ClientInterface
private $baseUri; // https://slack.com/api/
private $token; // 'OAuth Access Token' from 'OAuth & Permissions' > 'Tokens for Your Workspace' on https://api.slack.com/apps
// __construct...
public function getUser(string $userId): array
{
$options = [
'user' => $userId,
];
return $this->get('users.info', $options);
}
private function get(string $uri, array $options): array
{
$options['query'] = array_merge(
['token' => $this->token],
$options
);
return $this->handleResponse(
$this->client->get($this->baseUri . $uri, $options)
);
}
private function handleResponse(ResponseInterface $response): array
{
$data = json_decode($response->getBody()->getContents(), true);
if (JSON_ERROR_NONE !== json_last_error()) {
throw new \RuntimeException("Can't get Slack response");
}
if (!isset($data['ok']) || true !== $data['ok']) {
throw new \RuntimeException('Got error from Slack');
}
return $data;
}
}
Nous pouvons ainsi utiliser la méthode get
de ce service en lui passant le user ID récupéré dans le "content" précédent. En retour, nous obtenons :
{ "user": { "name": "Charles-Eric Gorron", ... "profile": { ... "email": "cgorron@eleven-labs.com" } } }
Ensuite nous utilisons un autre service src/AppBundle/Service/MemberHandler.php pour créer une instance de Member
avec ce nom et cet email, si elle n'existe pas déjà dans notre base de données.
Maintenant que nous avons le texte du message Slack ainsi que les données de l'utilisateur qui nous l'a envoyé, nous devons appeler DialogFlow via l'API "query" pour que celle-ci nous retourne la réponse à renvoyer à l'astronaute, ainsi que les valeurs de "startDate" et "endDate" qui nous intéressent.
Là encore nous utilisons un client Guzzle pour appeler cette API :
// src/AppBundle/DialogFlow/Client.php
<?php
namespace AppBundle\DialogFlow;
// use statements...
class Client
{
private $client; // GuzzleHttp\ClientInterface
private $baseUri; // https://api.dialogflow.com/v1/
private $token; // 'Client access token' from agent settings > 'General' tab > 'API KEYS (V1)' on https://console.dialogflow.com
// __construct...
public function query(string $message, string $sessionId): array
{
$options = [
'query' => [
'query' => $message,
'sessionId' => $sessionId,
'lang' => 'fr',
'v' => '20170712',
],
];
return $this->handleResponse(
$this->call('query', $options)
);
}
private function call(string $method, array $options): ResponseInterface
{
$options = array_merge(
['headers' => ['Authorization' => 'Bearer ' . $this->token]],
$options
);
return $this->client->get($this->baseUri . $method, $options);
}
private function handleResponse(ResponseInterface $response): array
{
$data = json_decode($response->getBody()->getContents(), true);
if (JSON_ERROR_NONE !== json_last_error()) {
throw new \RuntimeException("Can't get DialogFlow response");
}
return $data;
}
}
Cette réponse est sous cette forme :
"result": { ... "fulfillment": { ... "speech": "OK, c'est noté !", "messages": [ { "type": 4, "payload": { "startDate": "2018-01-06", "endDate": "2018-03-10" } }, ... ] } }
Ainsi nous utilisons un service src/AppBundle/DialogFlow/Parser.php pour extraire les informations intéressantes de cette réponse :
Pour cela, on peut ajouter une méthode dans notre service Client Slack pour appeler "chat.postMessage" :
// src/AppBundle/Slack/Client.php
...
public function postMessage(string $message, string $channel)
{
$payload = [
'text' => $message,
'channel' => $channel,
'username' => 'wilson-planning',
];
$response = $this->client->post(
$this->baseUri . 'chat.postMessage',
[
'headers' => [
'Authorization' => 'Bearer ' . $this->token,
'Content-Type' => 'application/json',
],
'body' => json_encode($payload, JSON_UNESCAPED_UNICODE),
]
);
return $this->handleResponse($response);
}
...
On donne en entrée ces arguments :
Le "token" à utiliser est le même que celui qu'on a envoyé lors de la requête GET
qui récupère les informations de l'utilisateur.
On a bien récupéré précédemment les informations de l'utilisateur qui nous ont permis de créer un Member
, et on a aussi les "startDate" et "endDate" retournées par DialogFlow. Il ne nous reste donc qu'à créer une instance de Vacation
et l'enregistrer en base de données.
Voir src/AppBundle/Service/VacationHandler.php pour plus de détails.
Il faut maintenant brancher tous ces services ensemble, ce qui est très simple puisque nous avons configuré nos services en "autowiring" : il suffit donc d'injecter les services au bon endroit dans le constructeur. Finalement voilà à quoi ressemble notre action de controller :
<?php
// src/AppBundle/Action/Webhook/SlackAction.php
namespace AppBundle\Action\Webhook;
// use statements...
final class SlackAction
{
// private properties and __construct...
/**
* @Route("/api/webhook/slack", defaults={"_format": "json"})
* @Method("POST")
*/
public function __invoke(Request $request): Response
{
// get $content from request, check 'token', verify 'challenge'...
try {
$userSlackId = $this->slackWebhookParser->getSenderId($content);
$member = $this->memberHandler->getOrCreateFromSlackId($userSlackId);
$dialogFlowResponse = $this->dialogFlowClient->query(
$this->slackWebhookParser->getMessage($content),
$userSlackId
);
$slackResponse = $this->slackClient->postMessage(
$this->dialogFlowParser->getSpeech($dialogFlowResponse),
$this->slackWebhookParser->getChannel($content)
);
$vacationDates = $this->dialogFlowParser->getMessageCustomPayload($dialogFlowResponse);
$this->vacationHandler->create($vacationDates['startDate'], $vacationDates['endDate'], $member);
} catch (\InvalidArgumentException $e) {
$this->logger->warning(
'Response not supported from Slack or DialogFlow: {exception}',
['exception' => $e]
);
}
return new Response('', 204);
}
}
Point d'attention : il faut bien prévoir tous les types de messages qu'on peut possiblement recevoir de Slack ou DialogFlow et éviter à tout prix les erreurs.
Voilà pourquoi je catch ici les \InvalidArgumentException
retournées par mes parsers.
Si votre webhook retourne un code d'erreur HTTP, Slack rappellera plusieurs fois votre webhook, jusqu'à obtenir une réponse avec un code 20X. Cela peut avoir des conséquences surprenantes : si l'erreur intervient à la dernière étape de votre controller, après le POST vers Slack, vous pourriez spammer la conversation privée de l'utilisateur en lui renvoyant un nouveau message à chaque fois que Slack rappelle le webhook en erreur !
Bien sûr, pour respecter les bonnes pratiques, il faudrait aussi déplacer toute la logique métier de ce controller vers un service dédié.
Démonstration : voici un extrait d'une conversation Slack avec notre bot :
Et voilà le résultat enregistré en base de données :
On remarque notre ami Google a bien su reconnaître les dates écrites en français et nous a permis d'enregistrer des dates au format "datetime" en base de données, merci à lui !
Je m'arrête ici pour cette fois, même si comme mentionné en première partie de cet article, il y aurait encore beaucoup à faire pour automatiser totalement ce process et ne plus jamais avoir besoin d'utiliser nos vieux ERPs : appels vers les API des calendars, utilisation des boutons Slack pour la validation, envoi de notifications Slack à tous les membres de la même équipe, ou même calcul automatique de la capacité du Sprint de l'équipe impactée par cette nouvelle demande de congés !
Vous noterez que j'ai utilisé API Platform sur mon projet Github, alors qu'il n'a aucun intérêt pour cet article en particulier : car j'ai encore beaucoup d'idées en tête à implémenter pour interagir avec d'autres systèmes qui pourraient appeler cette API.
Je vous tiendrai au courant des prochaines évolutions de cet outil si ça vous intéresse :P ! Faites moi savoir en commentaire si vous avez d'autres idées d'optimisations !
Auteur(s)
Charles-Eric Gorron
Software Solution Architect, Team Leader & Studio Director @ Eleven Labs
Vous souhaitez en savoir plus sur le sujet ?
Organisons un échange !
Notre équipe d'experts répond à toutes vos questions.
Nous contacterDécouvrez nos autres contenus dans le même thème
Découvrez un résumé concis des conférences qui nous ont le plus marqué lors du Forum PHP 2024 !
Le composant Symfony ExpressionLanguage : qu'est-ce que c'est ? Quand et comment l'utiliser ? Comment créer des expressions lors de cas plus complexes ?
Découvrez comment réaliser du typage générique en PHP : introduction et définition du concept, conseils et explications pas-à-pas de cas pratique.