Envoyer les notifications GitHub vers Telegram avec un webhook Symfony

Illustration de Envoyer les notifications GitHub vers Telegram avec un webhook Symfony

Un flux de webhook Symfony pour envoyer les notifications d’un dépôt GitHub vers Telegram.

J’utilise Telegram pour communiquer avec mon équipe. Ce n’est pas très conventionnel, je l’admets, mais c’est l’outil auquel je suis habitué. Il est possible de créer un bot sur Telegram. Comme je voulais recevoir les notifications GitHub directement dans Telegram, par exemple lorsqu’un collègue pousse du code sur un projet, j’ai exploré cette possibilité.

Qu’est-ce qu’un webhook ?

Un webhook permet à une application de transmettre des informations en temps réel à une autre. Contrairement aux API traditionnelles, où une application doit interroger l’autre pour obtenir des données, les webhooks envoient les données dès qu’un événement précis se produit. Ils sont particulièrement utiles pour recevoir des notifications immédiates d’événements tels que des commits ou des pull requests sur GitHub.

Tout mettre en place

Pour recevoir les notifications GitHub par webhook, je dois configurer des webhooks sur mon dépôt ou mon organisation GitHub. Voici comment procéder. Envoyez ensuite un message privé à https://telegram.me/BotFather pour créer un bot et obtenir un jeton d’API.

Passons au code !

Partons du principe que votre projet Symfony est déjà configuré. Je dois installer les composants Webhook et RemoteEvent de Symfony, ainsi que le client PHP de l’API Telegram. Je contribue par ailleurs à ce client API.

Bash
composer require symfony/webhook symfony/remote-event telegram-bot/api

Maintenant que les bibliothèques sont installées, configurons les variables d’environnement. Elles servent à stocker mes clés d’API. Pour renforcer la sécurité, je recommande d’utiliser le système de gestion des secrets de Symfony. Il permet de protéger ces informations sensibles.

Bash
# .env.local
TELEGRAM_API_TOKEN=xxxxxxxx:xxxxxxxxxxxxxxxxxxx
GITHUB_WEbHOOK_SECRET=xxxxxxxxxxxxxxxxxxxxxxxxxxxxx

Pour simplifier l’injection de ces valeurs, je peux les déclarer directement comme paramètres dans la configuration des services. L’autowiring me permettra ensuite d’y accéder facilement depuis n’importe quelle classe.

Avec cette configuration, Symfony injectera automatiquement les valeurs nécessaires là où j’en ai besoin :

YAML
# config/service.yaml
parameters:
    telegram_api_token: '%env(TELEGRAM_API_TOKEN)%'
    github_webhook_secret: '%env(GITHUB_WEbHOOK_SECRET)%'

Mon client API a besoin d’un jeton pour envoyer des requêtes à Telegram. Voici comment configurer une fois pour toutes l’injection de ce jeton à partir du paramètre défini plus haut :

YAML
# config/service.yaml
services:
    TelegramBot\Api\BotApi:
        arguments:
            - '%telegram_api_token%'

Créons maintenant les classes nécessaires au traitement des requêtes webhook envoyées par GitHub. Il m’en faut deux :

  • RequestParser : cette classe intercepte les requêtes POST au format JSON envoyées par GitHub et renvoie un objet RemoteEvent.

  • WebhookConsumer : cette classe reçoit un objet RemoteEvent et exécute la logique correspondante.

Grâce au composant Maker de Symfony, je n’ai pas besoin de les créer manuellement. La commande suivante les génère :

Bash
php bin/console make:webhook github

Les fichiers src/RemoteEvent/GithubWebhookConsumer.php et src/Webhook/GithubRequestParser.php seront générés. Avant d’aller plus loin, configurons le webhook. Cette classe fait également office de contrôleur, et le composant Webhook crée une route /webhook/{type} associée. Dans mon cas, il s’agit de http://localhost:8000/webhook/github, l’URL à renseigner sur GitHub. Vous pouvez utiliser ngrok pour effectuer des tests dans votre environnement local.

YAML
# config/packages/webhook.yaml
framework:
    webhook:
        routing:
            github:
                service: App\Webhook\GithubRequestParser
                secret: '%github_webhook_secret%'

github_webhook_secret ? Je ne l’avais pas encore mentionné : pour vérifier que les requêtes proviennent bien de GitHub, je peux ajouter un secret. GitHub l’utilisera pour générer une signature sha1 et sha256, ce qui me permettra de valider l’authenticité des requêtes.

GithubRequestParser

Cette classe comporte trois méthodes :

  • getRequestMatcher : vérifie que la requête reçue correspond à celle attendue. Par exemple, je peux exiger que toutes les requêtes provenant de GitHub utilisent la méthode POST et le format JSON, et que l’hôte soit « github.com ».

  • doParse : reçoit une requête et la transforme en instance de RemoteEvent.

  • validateSignature : valide la signature à l’aide de ma clé secrète.

PHP
use Symfony\Component\HttpFoundation\HeaderBag;
use Symfony\Component\HttpFoundation\ChainRequestMatcher;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\RequestMatcherInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;
use Symfony\Component\Webhook\Client\AbstractRequestParser;
use Symfony\Component\Webhook\Exception\RejectWebhookException;
use Symfony\Component\HttpFoundation\RequestMatcher\{
    MethodRequestMatcher,
    IsJsonRequestMatcher
};


final class GithubRequestParser extends AbstractRequestParser
{
    protected function getRequestMatcher(): RequestMatcherInterface
    {
        return new ChainRequestMatcher([
            new MethodRequestMatcher(Request::METHOD_POST),
            new IsJsonRequestMatcher()
        ]);
    }

    protected function doParse(
        Request $request,
        #[\SensitiveParameter] string $secret
    ): ?RemoteEvent {
        $this->validateSignature(
            headers: $request->headers,
            body: $request->getContent(),
            secret: $secret
        );

        return new RemoteEvent(
            name: $request->headers->get('X-GitHub-Event'),
            id: $request->headers->get('X-GitHub-Hook-ID'),
            payload: $request->getPayload()->all()
        );
    }

    private function validateSignature(
        HeaderBag $headers, string $body,
        #[\SensitiveParameter] string $secret
    ): void {
        $signature = hash_hmac('sha256', $body, $secret);

        if (!hash_equals($signature, $headers->get('X-Hub-Signature-256'))) {
            throw new RejectWebhookException(406, 'Invalid signature.');
        }
    }
}

GithubWebhookConsumer

Cette classe peut être considérée comme un service qui encapsule la logique métier. Dans mon cas, elle vérifie simplement le type d’événement envoyé par GitHub, puis utilise mon client Telegram pour transmettre la notification au groupe de discussion. Le bot doit bien sûr être ajouté au groupe. Pour obtenir le ChatId de votre groupe, consultez cette réponse.

PHP
use TelegramBot\Api\BotApi;
use Psr\Log\LoggerInterface;
use Symfony\Component\RemoteEvent\Attribute\AsRemoteEventConsumer;
use Symfony\Component\RemoteEvent\Consumer\ConsumerInterface;
use Symfony\Component\RemoteEvent\RemoteEvent;


#[AsRemoteEventConsumer('github')]
final readonly class GithubWebhookConsumer implements ConsumerInterface
{
    public function __construct(
        private BotApi $api,
        private LoggerInterface $logger
    ) {
    }

    public function consume(RemoteEvent $event): void
    {
        $name = $event->getName();

       try {
           match (true) {
               $name === 'push' => $this->handlePushEvent($event),
               $name === 'ping' => $this->handlePingEvent($event),
               default => null,
           };
       } catch (\Throwable $e) {
              $this->logger->error($e->getMessage());
       }
    }

    private function handlePushEvent(RemoteEvent $event): void
    {
        $data = $event->getPayload();
        $project = $data['repository']['full_name'];
        $pusher = $data['pusher']['name'];
        $description = $data['head_commit']['message'];
        $ref = str_replace('refs/heads/', '', $data['ref']);
        $commit = substr(strval($data['after']), 0, 8);

        $message = vsprintf(
            format: $commit === '00000000' ?
                '🔥 %s deleted %s on %s' :
                '🔥 %s pushed %s on %s : %s',
            values: [$pusher, $ref, $project, $description]
        );

        $this->sendMessage($message);
    }

    private function handlePingEvent(RemoteEvent $event): void
    {
        $data = $event->getPayload();
        $message = sprintf('👉 Github ping : %s', $data['zen']);
        $this->sendMessage($message);
    }

    private function sendMessage(?string $message = null): void
    {
        if ($message !== null) {
            $this->api->sendMessage(
                chatId: 'your group id',
                text: $message,
                disablePreview: true,
                messageThreadId: 'your topic id if any'
            );
        }
    }
}

Conclusion

Je peux désormais recevoir une notification chaque fois qu’un collègue pousse du code sur un projet. Mon équipe étant petite, le volume de notifications reste raisonnable.

Cela dit, l’utilisation des composants Webhook et RemoteEvent reste encore peu documentée. J’espère que cet article vous aidera à mieux comprendre leur fonctionnement.

Bon code !

Articles liés