Zaroz
Bot de Discord

Crea un bot de Discord que monitorice un servidor de Minecraft

En este tutorial crearás un bot pequeño con discord.js que consulta un servidor de Minecraft y mantiene un único embed de Discord con su estado. Solo edita el mensaje cuando algo cambia, así que no llena el canal con actualizaciones repetidas.

El bot usa GET /service-instances/{id}/resources y GET /service-instances/{id}/external-access de la referencia de la API. Las convenciones que comparten todas las llamadas están en Usar la API de Zaroz.

Qué hace el bot terminado

  • Consulta un servicio cada 60 segundos.
  • Muestra el estado general y el del servicio principal de Minecraft.
  • Incluye la dirección pública cuando la API la devuelve.
  • Cambia de color según esté funcionando, arrancando, detenido o fallando.
  • Encuentra su mensaje anterior después de reiniciarse y lo edita.
  • Muestra errores de la API en el mismo embed sin revelar el token.

1. Crea la aplicación de Discord

  1. Abre el Discord Developer Portal y crea una aplicación.
  2. En Bot, crea el usuario bot y guarda su token en un gestor de contraseñas.
  3. En OAuth2 → URL Generator, selecciona el scope bot.
  4. Concede View Channels, Send Messages, Embed Links y Read Message History.
  5. Usa la URL generada para invitarlo a tu servidor.
  6. Activa el modo desarrollador en Discord, haz clic derecho en el canal y copia su ID.

No hace falta activar Message Content ni ningún otro intent privilegiado.

2. Crea el token de Zaroz

En Ajustes → Seguridad → Tokens de API, crea discord-minecraft-status:

  • Capacidad: services.read
  • Alcance: Un recurso
  • Recurso: tu servidor de Minecraft (el id de instancia de servicio, como en la URL de su consola)

Es el único permiso de Zaroz que necesita este tutorial. Copia el token al crearlo; no volverá a mostrarse.

3. Crea el proyecto

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

Sustituye package.json por:

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

Crea index.js:

JavascriptCode
import "dotenv/config"; import { Client, EmbedBuilder, Events, GatewayIntentBits, } from "discord.js"; const requiredVariables = [ "DISCORD_TOKEN", "DISCORD_CHANNEL_ID", "ZAROZ_API_TOKEN", "ZAROZ_SERVICE_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/v2"; const pollInterval = Math.max( Number(process.env.POLL_INTERVAL_SECONDS ?? 60), 15, ) * 1_000; const marker = `zaroz-service:${process.env.ZAROZ_SERVICE_ID}`; const colours = { running: 0x22c55e, provisioning: 0x3b82f6, pending: 0x3b82f6, stopping: 0xf59e0b, suspended: 0x64748b, crash_looping: 0xef4444, degraded: 0xf97316, failed: 0xef4444, }; const client = new Client({ intents: [GatewayIntentBits.Guilds] }); let channel; let statusMessage; let lastFingerprint; let updateInProgress = false; async function zaroz(path) { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 10_000); try { const response = await fetch(`${API_BASE}${path}`, { 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); } } async function getStatus() { const id = process.env.ZAROZ_SERVICE_ID; const resources = await zaroz(`/service-instances/${id}/resources`); const primary = resources.data.find((item) => item.is_declared_primary); // The public address is optional: leave it out if this token cannot read it. let address; try { const access = await zaroz(`/service-instances/${id}/external-access`); const port = access.allocations.find((item) => item.is_primary); if (port) address = `${port.external_ip}:${port.external_port}`; } catch { address = undefined; } return { state: primary?.status ?? "pending", address }; } function buildStatusEmbed(status) { const fields = [ { name: "Minecraft service", value: status.state, inline: true }, ]; if (status.address) { fields.push({ name: "Address", value: `\`${status.address}\``, 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 getStatus(); const fingerprint = JSON.stringify(status); 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);

También puedes descargar package.json e index.js.

4. Configúralo y ejecútalo

Para probar en local, crea .env:

Code
DISCORD_TOKEN=tu-token-de-discord DISCORD_CHANNEL_ID=000000000000000000 ZAROZ_API_TOKEN=tu-token-de-zaroz ZAROZ_SERVICE_ID=00000000-0000-0000-0000-000000000000 POLL_INTERVAL_SECONDS=60

Añade .env a .gitignore y arranca:

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

Para alojarlo como servicio de Bot de Discord en Zaroz, sube package.json, package-lock.json e index.js, configura las cinco variables en el entorno del servicio y usa npm start como comando de inicio. No subas .env.

5. Comprueba el mínimo privilegio

El bot debe poder leer GET /service-instances/{id}/resources, pero POST /service-instances/{id}/restart debe devolver 403. Es lo esperado: una integración pública de estado no debería poder reiniciar el servidor.

Si más adelante añades un comando de reinicio, concede services.power solo a este servicio, limita el comando de Discord a roles de confianza y registra quién lo ejecutó.

Solución de problemas

El embed muestra HTTP 401

Sustituye un token caducado o revocado. Comprueba que copiaste el token completo y que el valor del entorno no contiene comillas.

El embed muestra HTTP 403

Concede services.read para este servicio exacto. El propietario también debe seguir pudiendo ver el servicio.

Crea un mensaje nuevo tras cada reinicio

Concede Read Message History y mantén el mensaje de estado entre los 50 más recientes del canal.

No aparece la dirección

La API solo incluye una dirección cuando el provision principal expone una. El monitor de estado sigue funcionando sin ella.

¿Cada cuánto debería consultar?

Sesenta segundos es un buen valor. El ejemplo impone un mínimo de 15 segundos. Consulta la referencia OpenAPI antes de aumentar el volumen de peticiones.

Last modified on