Aller au contenu

Créer un bot Discord qui surveille une commande Minecraft

Dans ce tutoriel, tu vas créer un petit bot discord.js qui interroge une commande Minecraft et maintient un seul embed Discord avec son état. Il ne modifie le message que lorsque l'état change, sans remplir le salon de répétitions.

Le bot utilise Get Order Status de la référence OpenAPI du Kernel.

Ce que fait le bot terminé

  • Lit une commande toutes les 60 secondes.
  • Affiche l'état global et celui du service Minecraft principal.
  • Ajoute l'adresse publique lorsque l'API en renvoie une.
  • Change la couleur de l'embed selon l'état.
  • Retrouve son ancien message après un redémarrage et le modifie.
  • Affiche une erreur API dans le même embed sans révéler le jeton.

1. Créer l'application Discord

  1. Ouvre le Discord Developer Portal et crée une application.
  2. Dans Bot, crée l'utilisateur bot et garde son jeton dans un gestionnaire de mots de passe.
  3. Dans OAuth2 → URL Generator, sélectionne le scope bot.
  4. Accorde View Channels, Send Messages, Embed Links et Read Message History.
  5. Invite le bot sur ton serveur avec l'URL générée.
  6. Active le mode développeur dans Discord, fais un clic droit sur le salon et copie son ID.

Le bot n'a pas besoin de Message Content ni d'un autre intent privilégié.

2. Créer le jeton Zaroz

Dans Paramètres → Sécurité → Jetons API, crée discord-minecraft-status :

  • Capacité : orders.read
  • Portée : Une ressource
  • Ressource : ta commande Minecraft

C'est la seule capacité Zaroz nécessaire. Copie le jeton dès sa création ; il ne sera plus affiché.

3. Créer le projet

mkdir zaroz-minecraft-status
cd zaroz-minecraft-status
npm init -y
npm install discord.js dotenv

Remplace package.json par :

package.json
{
  "name": "zaroz-minecraft-status",
  "private": true,
  "type": "module",
  "scripts": {
    "start": "node index.js"
  },
  "dependencies": {
    "discord.js": "^14.26.5",
    "dotenv": "^17.4.2"
  }
}

Crée index.js :

index.js
import "dotenv/config";
import {
  Client,
  EmbedBuilder,
  Events,
  GatewayIntentBits,
} from "discord.js";

const requiredVariables = [
  "DISCORD_TOKEN",
  "DISCORD_CHANNEL_ID",
  "ZAROZ_API_TOKEN",
  "ZAROZ_ORDER_ID",
];

for (const variable of requiredVariables) {
  if (!process.env[variable]) {
    throw new Error(`Missing environment variable: ${variable}`);
  }
}

const API_BASE = "https://kernel.zaroz.cloud/api/v1";
const pollInterval = Math.max(
  Number(process.env.POLL_INTERVAL_SECONDS ?? 60),
  15,
) * 1_000;
const marker = `zaroz-order:${process.env.ZAROZ_ORDER_ID}`;

const colours = {
  Running: 0x22c55e,
  Provisioning: 0x3b82f6,
  Stopping: 0xf59e0b,
  Suspended: 0x64748b,
  NotProvisioned: 0x64748b,
  CrashLooping: 0xef4444,
  Degraded: 0xf97316,
  Failed: 0xef4444,
};

const client = new Client({ intents: [GatewayIntentBits.Guilds] });
let channel;
let statusMessage;
let lastFingerprint;
let updateInProgress = false;

async function getOrderStatus() {
  const controller = new AbortController();
  const timeout = setTimeout(() => controller.abort(), 10_000);

  try {
    const response = await fetch(
      `${API_BASE}/orders/${process.env.ZAROZ_ORDER_ID}/status`,
      {
        headers: {
          Authorization: `Bearer ${process.env.ZAROZ_API_TOKEN}`,
          Accept: "application/json",
        },
        signal: controller.signal,
      },
    );

    if (!response.ok) {
      throw new Error(`Zaroz API returned HTTP ${response.status}`);
    }

    return response.json();
  } finally {
    clearTimeout(timeout);
  }
}

function buildStatusEmbed(status) {
  const primary = status.provisions.find((item) => item.is_primary);
  const fields = [
    { name: "Order", value: status.state, inline: true },
    {
      name: "Minecraft service",
      value: primary?.status ?? "Not provisioned",
      inline: true,
    },
  ];

  if (primary?.external_ip && primary?.external_port) {
    fields.push({
      name: "Address",
      value: `\`${primary.external_ip}:${primary.external_port}\``,
      inline: false,
    });
  }

  if (status.failure_info?.message) {
    fields.push({
      name: "Startup failure",
      value: status.failure_info.message.slice(0, 1_024),
      inline: false,
    });
  }

  return new EmbedBuilder()
    .setTitle("Minecraft server status")
    .setColor(colours[status.state] ?? 0x64748b)
    .addFields(fields)
    .setFooter({ text: marker })
    .setTimestamp();
}

function buildErrorEmbed(error) {
  return new EmbedBuilder()
    .setTitle("Minecraft server status unavailable")
    .setDescription(error.message.slice(0, 2_000))
    .setColor(0xef4444)
    .setFooter({ text: marker })
    .setTimestamp();
}

async function findPreviousMessage() {
  const messages = await channel.messages.fetch({ limit: 50 });

  return messages.find(
    (message) =>
      message.author.id === client.user.id &&
      message.embeds.some((embed) => embed.footer?.text === marker),
  );
}

async function publish(embed) {
  statusMessage ??= await findPreviousMessage();

  if (statusMessage) {
    statusMessage = await statusMessage.edit({ embeds: [embed] });
  } else {
    statusMessage = await channel.send({ embeds: [embed] });
  }
}

async function updateStatus() {
  if (updateInProgress) return;
  updateInProgress = true;

  try {
    const status = await getOrderStatus();
    const primary = status.provisions.find((item) => item.is_primary);
    const fingerprint = JSON.stringify({
      state: status.state,
      primaryStatus: primary?.status,
      address: primary?.external_ip,
      port: primary?.external_port,
      failure: status.failure_info?.message,
    });

    if (fingerprint !== lastFingerprint) {
      await publish(buildStatusEmbed(status));
      lastFingerprint = fingerprint;
    }
  } catch (error) {
    const safeError =
      error instanceof Error ? error : new Error("Unknown status error");
    const fingerprint = `error:${safeError.message}`;

    if (fingerprint !== lastFingerprint) {
      await publish(buildErrorEmbed(safeError));
      lastFingerprint = fingerprint;
    }

    console.error("Status update failed:", safeError.message);
  } finally {
    updateInProgress = false;
  }
}

client.once(Events.ClientReady, async (readyClient) => {
  console.log(`Logged in as ${readyClient.user.tag}`);
  channel = await readyClient.channels.fetch(
    process.env.DISCORD_CHANNEL_ID,
  );

  if (!channel?.isTextBased() || !("messages" in channel)) {
    throw new Error("DISCORD_CHANNEL_ID is not a text channel");
  }

  await updateStatus();
  setInterval(updateStatus, pollInterval);
});

client.login(process.env.DISCORD_TOKEN);

Tu peux aussi télécharger package.json et index.js.

4. Configurer et lancer

Pour un test local, crée .env :

.env
DISCORD_TOKEN=ton-jeton-bot-discord
DISCORD_CHANNEL_ID=000000000000000000
ZAROZ_API_TOKEN=ton-jeton-api-zaroz
ZAROZ_ORDER_ID=00000000-0000-0000-0000-000000000000
POLL_INTERVAL_SECONDS=60

Ajoute .env à .gitignore, puis lance le bot :

printf '.env\n' >> .gitignore
npm start

Pour une commande Bot Discord Zaroz, envoie package.json, package-lock.json et index.js, configure les cinq variables dans l'environnement de la commande et utilise npm start comme commande de démarrage. N'envoie pas .env.

5. Vérifier le moindre privilège

Le bot doit pouvoir lire /orders/{id}/status, mais recevoir 403 sur /orders/{id}/toggle. C'est volontaire : une intégration publique de statut ne doit pas pouvoir redémarrer le serveur.

Si tu ajoutes plus tard une commande de redémarrage, accorde orders.server.toggle uniquement pour cette commande, réserve la commande Discord à des rôles de confiance et journalise son auteur.

Dépannage

L'embed affiche HTTP 401

Remplace un jeton expiré ou révoqué. Vérifie que tu as copié le jeton entier et sans guillemets dans la variable d'environnement.

L'embed affiche HTTP 403

Accorde orders.read pour cette commande précise. Le propriétaire doit lui aussi conserver l'accès.

Le bot crée un nouveau message après chaque redémarrage

Accorde Read Message History et garde le message de statut parmi les 50 derniers messages du salon.

L'adresse n'apparaît pas

L'API ne renvoie une adresse que si le provision principal en expose une. Le suivi d'état fonctionne quand même.

À quelle fréquence interroger l'API ?

60 secondes est un bon réglage. L'exemple impose un minimum de 15 secondes. Consulte la référence OpenAPI avant d'augmenter le volume.