Crea un bot de Discord que monitorice un pedido de Minecraft¶
En este tutorial crearás un bot pequeño con discord.js que consulta un pedido 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 Order Status de la referencia OpenAPI del Kernel.
Qué hace el bot terminado¶
- Consulta un pedido 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¶
- Abre el Discord Developer Portal y crea una aplicación.
- En Bot, crea el usuario bot y guarda su token en un gestor de contraseñas.
- En OAuth2 → URL Generator, selecciona el scope
bot. - Concede View Channels, Send Messages, Embed Links y Read Message History.
- Usa la URL generada para invitarlo a tu servidor.
- 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:
orders.read - Alcance: Un recurso
- Recurso: tu pedido de Minecraft
Es el único permiso de Zaroz que necesita este tutorial. Copia el token al crearlo; no volverá a mostrarse.
3. Crea el proyecto¶
Sustituye package.json por:
{
"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:
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);
También puedes descargar package.json e index.js.
4. Configúralo y ejecútalo¶
Para probar en local, crea .env:
DISCORD_TOKEN=tu-token-de-discord
DISCORD_CHANNEL_ID=000000000000000000
ZAROZ_API_TOKEN=tu-token-de-zaroz
ZAROZ_ORDER_ID=00000000-0000-0000-0000-000000000000
POLL_INTERVAL_SECONDS=60
Añade .env a .gitignore y arranca:
Para alojarlo como pedido de Bot de Discord en Zaroz, sube package.json, package-lock.json e index.js, configura las cinco variables en el entorno del pedido y usa npm start como comando de inicio. No subas .env.
5. Comprueba el mínimo privilegio¶
El bot debe poder leer /orders/{id}/status, pero una petición a /orders/{id}/toggle 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 orders.server.toggle solo a este pedido, 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 orders.read para este pedido exacto. El propietario también debe conservar acceso al pedido.
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.