Ir para o conteúdo

Criar um bot do Discord que monitoriza uma encomenda Minecraft

Neste tutorial vais criar um pequeno bot com discord.js que consulta uma encomenda Minecraft e mantém um único embed do Discord com o estado atual. Só edita a mensagem quando algo muda, sem encher o canal de atualizações repetidas.

O bot usa Get Order Status da referência OpenAPI do Kernel.

O que o bot final faz

  • Lê uma encomenda a cada 60 segundos.
  • Mostra o estado geral e o estado do serviço Minecraft principal.
  • Inclui o endereço público quando a API o devolve.
  • Muda a cor do embed conforme o estado.
  • Encontra a mensagem anterior depois de reiniciar e edita-a.
  • Mostra erros da API no mesmo embed sem revelar o token.

1. Criar a aplicação Discord

  1. Abre o Discord Developer Portal e cria uma aplicação.
  2. Em Bot, cria o utilizador bot e guarda o respetivo token num gestor de palavras-passe.
  3. Em OAuth2 → URL Generator, seleciona o scope bot.
  4. Concede View Channels, Send Messages, Embed Links e Read Message History.
  5. Usa o URL gerado para convidar o bot para o teu servidor.
  6. Ativa o modo de programador no Discord, clica com o botão direito no canal e copia o ID.

O bot não precisa de Message Content nem de outro gateway intent privilegiado.

2. Criar o token Zaroz

Em Definições → Segurança → Tokens de API, cria discord-minecraft-status:

  • Capacidade: orders.read
  • Âmbito: Um recurso
  • Recurso: a tua encomenda Minecraft

É a única capacidade Zaroz necessária. Copia o token quando aparecer; não será mostrado novamente.

3. Criar o projeto

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

Substitui package.json por:

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"
  }
}

Cria 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);

Também podes descarregar package.json e index.js.

4. Configurar e executar

Para testar localmente, cria .env:

.env
DISCORD_TOKEN=o-teu-token-do-discord
DISCORD_CHANNEL_ID=000000000000000000
ZAROZ_API_TOKEN=o-teu-token-da-zaroz
ZAROZ_ORDER_ID=00000000-0000-0000-0000-000000000000
POLL_INTERVAL_SECONDS=60

Adiciona .env a .gitignore e inicia:

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

Para uma encomenda Bot do Discord na Zaroz, envia package.json, package-lock.json e index.js, define as cinco variáveis no ambiente da encomenda e usa npm start como comando de arranque. Não envies .env.

5. Confirmar o menor privilégio

O bot deve conseguir ler /orders/{id}/status, mas receber 403 em /orders/{id}/toggle. É intencional: uma integração pública de estado não deve conseguir reiniciar o servidor.

Se adicionares mais tarde um comando de reinício, concede orders.server.toggle apenas para esta encomenda, restringe o comando do Discord a cargos de confiança e regista quem o executou.

Resolução de problemas

O embed mostra HTTP 401

Substitui um token expirado ou revogado. Confirma que copiaste o token completo e sem aspas no valor do ambiente.

O embed mostra HTTP 403

Concede orders.read para esta encomenda exata. O proprietário também tem de manter acesso.

O bot cria uma mensagem nova depois de cada reinício

Concede Read Message History e mantém a mensagem de estado entre as 50 mensagens mais recentes do canal.

O endereço não aparece

A API só inclui um endereço quando o provision principal expõe um. O monitor continua a funcionar sem ele.

Com que frequência devo consultar?

60 segundos é um bom valor. O exemplo impõe um mínimo de 15 segundos. Consulta a referência OpenAPI antes de aumentares o volume.