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¶
- Abre o Discord Developer Portal e cria uma aplicação.
- Em Bot, cria o utilizador bot e guarda o respetivo token num gestor de palavras-passe.
- Em OAuth2 → URL Generator, seleciona o scope
bot. - Concede View Channels, Send Messages, Embed Links e Read Message History.
- Usa o URL gerado para convidar o bot para o teu servidor.
- 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¶
Substitui 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"
}
}
Cria 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:
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:
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.