Créer des commandes slash personnalisées sur Discord

Ajout de commandes slash personnalisées pour le serveur Discord de Moonlight Game Streaming

Un peu de contexte : je suis un administrateur du serveur Discord officiel Moonlight Game Streaming, que j’ai créé en 2018.

Dans cet article de blog, je vais raconter notre évolution à passer de bots Discord pré-faits à notre propre solution pour nos commandes personnalisées, et pourquoi.

C’est quoi le problème ?

Beaucoup de fois, les personnes viennent demander les mêmes types de questions avec les mêmes types de réponses, ou plus généralement on se retrouve à se répéter souvent.

Il nous fallait une façon de répondre rapidement à ces demandes avec des guides, des liens, et d’autres ressources pour nos membres. Il nous fallait une fonction “réponses rapides”, en fait.

Pas le premier, ni le dernier

Bien sûr, on n’est pas les premiers à rencontrer ce problème ou à souhaiter cette fonctionnalité. De nombreux bots Discord disposent de cette fonctionnalité, où vous enregistrez un nom que les membres peuvent utiliser pour invoquer une commande, ainsi que l’action à exécuter lorsque cette commande est appelée (la plupart du temps, envoyer une réponse personnalisée au membre).

Nous avons utilisé plusieurs de ces bots pour faire exactement cela (et plus encore) : Dyno, Mee6, YAGPDB, et même, à un moment donné, notre bot Nadko auto-hébergé.

Ils fonctionnaient tous comme prévu, mais chacun avait son propre fonctionnement : son propre tableau de bord, son propre préfixe de commande et ses propres limitations. Et au bout d’un certain temps, nous avons commencé à atteindre ces limitations.

Par exemple, Dyno limitait le nombre de commandes à une centaine environ au début de notre utilisation. Cependant, après un certain temps, cette limite a été abaissée. Heureusement, nous n’avons pas perdu l’accès à ces commandes. Elles restaient utilisables, mais nous ne pouvions plus en créer de nouvelles.

Où es-tu ?

Ils ont également un problème de découverte : puisqu’ils sont invoqués par un membre en envoyant un message avec un préfixe (comme ?) et le nom de la commande, ils doivent connaître ce nom à l’avance, ou l’obtenir à partir d’une liste dans un canal ou en utilisant une commande comme ?aide pour l’obtenir.

Les commandes slash intégrées à Discord ne présentent pas ce problème : lorsque vous commencez à taper /, le client affiche automatiquement la liste des commandes disponibles et vous permet de les parcourir. Par exemple, taper « wiki » suggère automatiquement la commande /wiki. Il propose également la saisie semi-automatique pour les commandes plus complexes avec options : par exemple, notre commande /guide-installation possède une option permettant d’accéder à une section spécifique du guide d’installation.

Le faire soi-même ?

Compte tenu des limites de nos solutions actuelles et de la gestion de plusieurs bots, j’ai décidé de commencer à explorer les commandes slash de Discord pour voir si je ne pouvais pas recréer cette fonctionnalité moi-même.

Inscrivez-vous pour plus

Avant de pouvoir recevoir et traiter des commandes, vous devez d’abord les enregistrer avec Discord. Pour ce faire, il faut envoyer une requête API à Discord avec un corps JSON définissant les propriétés de cette commande, telles que son type, son nom et sa description.

C’est facile pour les commandes globales et statiques, comme la commande /guide-installation, parce qu’elle retournera toujours le même message, comme défini dans le code.

Voici un exemple simple d’enregistrement de la commande /wiki :

{
  "type": 1,
  "name": "wiki",
  "description": "Get a link to the wiki"
}

Mais les commandes peuvent devenir complexe vite. Voici un exemple avec la commande /guide-installation, avec options et traductions :

{
  "type": 1,
  "name": "setup-guide",
  "name_localizations": {
    "fr": "guide-installation"
  },
  "description": "Get a link to the setup guide",
  "description_localizations": {
    "fr": "Obtenir un lien vers le guide d'installation"
  },
  "options": [
    {
      "type": 3,
      "name": "section",
      "name_localizations": {
        "fr": "section"
      },
      "description": "The section of the setup guide to link to",
      "description_localizations": {
        "fr": "La section du guide à inclure dans le lien"
      },
      "choices": [
        {
          "name": "Quick Setup Instructions",
          "value": "quick-setup-instructions"
        },
        {
          "name": "Streaming over the Internet",
          "value": "streaming-over-the-internet"
        },
        {
          "name": "Moonlight Client Setup Instructions",
          "value": "moonlight-client-setup-instructions"
        },
        {
          "name": "Additional Requirements for HDR Streaming",
          "value": "additional-requirements-for-hdr-streaming"
        },
        {
          "name": "Keyboard/Mouse/Gamepad Input Options",
          "value": "keyboardmousegamepad-input-options"
        },
        {
          "name": "Adding custom programs that are not automatically found",
          "value": "adding-custom-programs-that-are-not-automatically-found"
        },
        {
          "name": "Using Moonlight to stream your entire desktop",
          "value": "using-moonlight-to-stream-your-entire-desktop"
        }
      ]
    }
  ]
}

Les commandes dynamiques sont plus complexes, cependant, parce qu’elles doivent être enregistrées avec l’API Discord au moment de leur création. Je n’irai pas dans les détails ici, mais il m’a fallu plusieurs tentatives pour que ça marche.

// Try creating the command on Discord
try {
  const response = await fetch(
    `https://discord.com/api/v10/applications/${env.DISCORD_APPLICATION_ID}/guilds/${interaction.guild_id}/commands`,
    {
      method: "POST",
      body: JSON.stringify({ name, description }),
      headers: {
        Authorization: "Bot " + env.DISCORD_TOKEN,
        "Content-Type": "application/json",
      },
    }
  );

  if (!response.ok) {
    return messageResponse({
      content: "An error occurred while creating the command on Discord",
    });
  }

  // Saving the command in Cloudflare KV goes here

  return messageResponse({
    content: `Your command has been successfully created: </${command.name}:${command.id}>`,
  });
} catch (error) {
  return messageResponse({
    content: "An error occurred while creating the command",
  });
}

Une fois ça fait, on peut maintenant lister les commandes disponibles, les auto-compléter, et les envoyer pour exécution.

Les interactions, c’est dur

Maintenant que nos commandes sont enregistrées, nous sommes prêts à recevoir et à traiter leurs invocations.

Les commandes slash font partie des interactions Discord. Vous pouvez déclencher une interaction via une “entrée de chat” ou une action contextuelle sur un message ou un membre.

Deux options permettent de recevoir ces interactions : via la passerelle en temps réel ou via un webhook. J’ai opté pour le webhook en raison du choix d’hébergement que j’aborderai plus tard. Ainsi, à chaque invocation d’une commande, une interaction est envoyée sous forme de requête HTTP à l’URL du webhook, définie dans le portail développeur Discord.

Lorsqu’une requête est reçue, il faut analyser le JSON joint au corps de la requête pour identifier la commande invoquée.

Une fois la commande à exécuter identifiée, il suffit de renvoyer une réponse d’interaction à la requête webhook de Discord. Concrètement, cela consiste à lui demander de répondre par un message contenant le contenu de la commande, tel que défini dans la base de données ou dans le code.

Pour les commandes statiques, regarder le nom de la commande suffit :

// Wiki command
if (interaction.data.name === "wiki") {
  return Response.json({
    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
    data: {
      content: "https://github.com/moonlight-stream/moonlight-docs/wiki",
    },
  });
}

Les commandes dynamiques, elles, requièrent de demander à la base de données :

// Custom commands
const command = await env.DISCORD_CUSTOM_COMMANDS.get(
  interaction.guild_id + ":" + interaction.data.id
);
if (command) {
  return Response.json({
    type: InteractionResponseType.CHANNEL_MESSAGE_WITH_SOURCE,
    data: { content: command },
  });
}

Les commandes personnalisées sont stockées dans Cloudflare KV, une base de données clé-valeur (D1, une base de données SQL, n’existait pas à l’époque).

J’vous héberge

L’application est hébergée par Cloudflare Workers sur mon compte gratuit personnel. Les déploiements sont automatisés à l’aide de GitHub Actions, grâce à l’action officielle de Cloudflare, Wrangler. Quand un commit est envoyé, un nouveau déploiement est réalisé.

name: Deploy
on:
  push:
    branches:
      - main
jobs:
  deploy:
    runs-on: ubuntu-latest
    name: Deploy
    steps:
      - uses: actions/checkout@v4
      - name: Deploy
        uses: cloudflare/wrangler-action@v3
        with:
          apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }}
          wranglerVersion: "4.38.0"

Utiliser la version gratuite signifie qu’on est limité à 100 000 requêtes par jour, mais ça devrait être plus qu’assez, surtout qu’en ce moment les commandes ne sont exécutées que par notre équipe, pas par nos presque 50 000 membres.

Les choses telles qu’elles sont

Aujourd’hui, à la fin de l’année 2025, nous avons notre appli Discord en route et qui réponds aux commandes de notre équipe uniquement, comme une sorte de bêta. Le système est pour l’instant sans bug, mais quand on ouvrira les commandes à tout le monde, ça pourra changer.

Le code source pour cette application est ouvert à l’adresse suivante, mais attention, le code est moche (mais il marche !) : https://github.com/jorys-paulin/moonlight-discord

Merci d’avoir lu !