# 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](/api). Las convenciones que comparten todas las llamadas están en [Usar la API de Zaroz](/es/guides/api/overview).

## 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](https://discord.com/developers/applications) 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

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

Sustituye `package.json` por:

```json title="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"
  }
}
```

Crea `index.js`:

```js title="index.js"
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](/examples/minecraft-status-bot/package.json) e [index.js](/examples/minecraft-status-bot/index.js).

## 4. Configúralo y ejecútalo

Para probar en local, crea `.env`:

```dotenv title=".env"
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:

```bash
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

<details>
<summary>El embed muestra HTTP 401</summary>

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

</details>

<details>
<summary>El embed muestra HTTP 403</summary>

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

</details>

<details>
<summary>Crea un mensaje nuevo tras cada reinicio</summary>

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

</details>

<details>
<summary>No aparece la dirección</summary>

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

</details>

<details>
<summary>¿Cada cuánto debería consultar?</summary>

Sesenta segundos es un buen valor. El ejemplo impone un mínimo de 15 segundos. Consulta la [referencia OpenAPI](https://kernel.zaroz.cloud/docs) antes de aumentar el volumen de peticiones.

</details>
