Aller au contenu

Interactions HTTP et événements webhook

Discord peut envoyer les interactions à une URL de votre application plutôt qu'à la gateway, et il envoie certains événements, les événements webhook, uniquement à une URL. ASGIApp sert les deux depuis le même bot, avec ses commandes, ses routes et ses écouteurs inchangés.

import logging

import bostie
from bostie import Context
from bostie.events import ApplicationAuthorized, EntitlementCreate

log = logging.getLogger("my-bot")
bot = bostie.Bot()
app = bostie.ASGIApp(bot)


@bot.command
async def ping(ctx: Context) -> None:
    """Answer over HTTP."""
    await ctx.respond("pong")


@bot.listen
async def installed(event: ApplicationAuthorized) -> None:
    where = "a guild" if event.guild is not None else "a user account"
    log.info("%s added the app to %s", event.user.username, where)


@bot.listen
async def purchased(event: EntitlementCreate) -> None:
    log.info("new entitlement %d", event.entitlement.id)

bostie.ASGIApp(bot) est une application ASGI : servez app avec n'importe quel serveur ASGI, derrière HTTPS. Ensuite, dans le Developer Portal, réglez l'Interactions Endpoint URL de l'application et, pour les événements webhook, sa Webhook Events URL, avec les types d'événements voulus, sur l'adresse où elle écoute. Discord vérifie un endpoint quand vous l'enregistrez, avec un PING et une requête dotée d'une signature erronée, et ASGIApp répond aux deux comme Discord l'attend.

Ce qu'elle fait

Au démarrage du serveur, via le lifespan ASGI, l'application appelle bot.open(), sauf si le bot tourne déjà sur la gateway, ce qui :

  1. ouvre le client HTTP du bot ;
  2. récupère l'application, pour son id et la clé publique qui signe les requêtes de Discord, ainsi que l'utilisateur du bot ;
  3. envoie Starting, synchronise les commandes et lance les tâches de fond.

Un échec à ce stade arrête le démarrage du serveur avec l'erreur. À l'arrêt du serveur, l'application ferme le bot si c'est elle qui l'a démarré.

Chaque requête est vérifiée avant toute autre chose : seul POST est accepté (405 sinon), les corps sont plafonnés à 4 Mio (413), et une requête dont la X-Signature-Ed25519 ne se vérifie pas avec la clé publique reçoit 401. Ensuite :

  • Un PING d'interaction reçoit le PONG que Discord attend.
  • Toute autre interaction reçoit 202 et est confiée au bot, qui exécute son gestionnaire de commande, de route ou de fenêtre modale comme il le ferait pour la gateway ; le gestionnaire répond par l'API REST de Discord, avec ctx.respond et les autres méthodes. La réponse différée automatique fonctionne de la même façon.
  • Un événement webhook atteint les écouteurs du bot et reçoit 204, tout comme le PING que Discord envoie à la Webhook Events URL.

ASGIApp(bot, token=..., public_key=...) accepte le token, sinon BOSTIE_TOKEN, et la clé publique, sinon celle que l'application déclare.

Avec ou sans la gateway

Servie seule, l'application n'a pas de connexion à la gateway : pas d'événements de la gateway, pas de mises à jour du cache, pas de voix. Les commandes, les composants, les fenêtres modales, REST et les événements webhook fonctionnent. Pour avoir les deux, démarrez aussi le bot sur la gateway, dans la même boucle d'événements : l'application utilise alors le bot en cours d'exécution ; les interactions arrivent par HTTP et la gateway livre toujours les événements.

Événements webhook

Événement Envoyé quand
ApplicationAuthorized un utilisateur a ajouté l'application à un serveur ou à son compte
ApplicationDeauthorized un utilisateur a retiré l'application, ou ses tokens OAuth2 ont expiré
EntitlementCreate, EntitlementUpdate, EntitlementDelete un entitlement a changé
QuestUserEnrollment un utilisateur a été inscrit à une Quête ; Discord le documente mais ne l'envoie pas encore aux applications
LobbyMessageCreate, LobbyMessageUpdate, LobbyMessageDelete un message a été envoyé, modifié ou supprimé dans un lobby du Social SDK
GameDirectMessageCreate, GameDirectMessageUpdate, GameDirectMessageDelete un message privé d'une session du Social SDK a changé

Écoutez-les avec @bot.listen, comme les événements de la gateway. Un type d'événement webhook que bostie ne modélise pas arrive sous forme de RawEvent.

Dans votre propre serveur web

Les deux briques qu'utilise ASGIApp sont publiques, pour un framework web que vous faites déjà tourner :

import logging
import os
from collections.abc import Mapping

from bostie import decode_webhook_event, verify_signature
from bostie.events import ApplicationDeauthorized, RawEvent

log = logging.getLogger("my-server")
PUBLIC_KEY = os.environ.get("DISCORD_PUBLIC_KEY", "")


def webhook_event(headers: Mapping[str, str], body: bytes) -> int:
    signature = headers.get("x-signature-ed25519", "")
    timestamp = headers.get("x-signature-timestamp", "")
    if not verify_signature(PUBLIC_KEY, signature, timestamp, body):
        return 401
    event = decode_webhook_event(body)
    if isinstance(event, ApplicationDeauthorized):
        log.info("%s removed the app", event.user.username)
    elif isinstance(event, RawEvent):
        log.info("an event bostie does not model: %s", event.name)
    return 204

verify_signature(public_key, signature, timestamp, body) vérifie les en-têtes X-Signature-Ed25519 et X-Signature-Timestamp d'une requête avec la clé publique hexadécimale de l'application ; répondez 401 quand elle renvoie False. decode_webhook_event(body) renvoie l'événement typé d'une requête Webhook Events, un RawEvent pour un type que bostie ne modélise pas, ou None pour le PING. Pour des interactions dans votre propre serveur, l'exemple d'interactions HTTP montre la voie ASGI, celle que bostie prend en charge.