Einen Discord-Bot zur Überwachung einer Minecraft-Bestellung bauen¶
In diesem Tutorial baust du einen kleinen discord.js-Bot, der eine Minecraft-Bestellung abfragt und ein einzelnes Discord-Embed mit ihrem aktuellen Zustand pflegt. Er bearbeitet die Nachricht nur bei Änderungen und flutet den Kanal daher nicht.
Der Bot verwendet Get Order Status aus der Kernel-OpenAPI-Referenz.
Das kann der fertige Bot¶
- Eine Bestellung alle 60 Sekunden lesen.
- Gesamtzustand und Zustand des primären Minecraft-Dienstes anzeigen.
- Die öffentliche Adresse einblenden, wenn die API eine liefert.
- Die Embed-Farbe für laufende, wechselnde, gestoppte und fehlerhafte Zustände ändern.
- Nach einem Neustart seine vorherige Statusnachricht finden und bearbeiten.
- API-Fehler im selben Embed anzeigen, ohne das Token offenzulegen.
1. Discord-Anwendung erstellen¶
- Öffne das Discord Developer Portal und erstelle eine Anwendung.
- Erstelle unter Bot den Bot-Benutzer und sichere sein Token im Passwortmanager.
- Wähle unter OAuth2 → URL Generator den Scope
bot. - Vergib View Channels, Send Messages, Embed Links und Read Message History.
- Lade den Bot über die erzeugte URL auf deinen Server ein.
- Aktiviere den Entwicklermodus in Discord, klicke den Zielkanal rechts an und kopiere seine ID.
Message Content und andere privilegierte Gateway-Intents sind nicht nötig.
2. Zaroz-API-Token erstellen¶
Erstelle unter Einstellungen → Sicherheit → API-Tokens ein Token namens discord-minecraft-status:
- Fähigkeit:
orders.read - Bereich: Eine Ressource
- Ressource: deine Minecraft-Bestellung
Das ist die einzige Zaroz-Fähigkeit für dieses Tutorial. Kopiere das Token sofort; es wird nicht erneut angezeigt.
3. Projekt erstellen¶
Ersetze package.json durch:
{
"name": "zaroz-minecraft-status",
"private": true,
"type": "module",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"discord.js": "^14.26.5",
"dotenv": "^17.4.2"
}
}
Erstelle 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);
Du kannst package.json und index.js auch direkt herunterladen.
4. Konfigurieren und starten¶
Erstelle für einen lokalen Test .env:
DISCORD_TOKEN=dein-discord-bot-token
DISCORD_CHANNEL_ID=000000000000000000
ZAROZ_API_TOKEN=dein-zaroz-api-token
ZAROZ_ORDER_ID=00000000-0000-0000-0000-000000000000
POLL_INTERVAL_SECONDS=60
Füge .env zu .gitignore hinzu und starte:
Für eine Zaroz-Discord-Bot-Bestellung lädst du package.json, package-lock.json und index.js hoch, legst die fünf Variablen in der Umgebungskonfiguration fest und verwendest npm start als Startbefehl. Lade .env nicht hoch.
5. Minimale Rechte prüfen¶
Der Bot soll /orders/{id}/status lesen können, aber auf /orders/{id}/toggle ein 403 erhalten. Das ist beabsichtigt: Eine öffentliche Statusintegration soll den Server nicht neu starten können.
Fügst du später einen Neustartbefehl hinzu, vergib orders.server.toggle nur für diese Bestellung, beschränke den Discord-Befehl auf vertrauenswürdige Rollen und protokolliere den Auslöser.
Fehlerbehebung¶
Das Embed zeigt HTTP 401
Ersetze ein abgelaufenes oder widerrufenes Token. Prüfe, ob du das vollständige Token ohne zusätzliche Anführungszeichen gespeichert hast.
Das Embed zeigt HTTP 403
Vergib orders.read für genau diese Bestellung. Der Besitzer muss ebenfalls weiterhin Zugriff haben.
Nach jedem Neustart entsteht eine neue Nachricht
Vergib Read Message History und halte die Statusnachricht unter den letzten 50 Nachrichten des Kanals.
Die Adresse fehlt
Die API liefert nur dann eine Adresse, wenn der primäre Provision eine bereitstellt. Die Zustandsanzeige funktioniert trotzdem.
Wie oft sollte ich abfragen?
60 Sekunden sind ein guter Standard. Das Beispiel erzwingt mindestens 15 Sekunden. Prüfe die OpenAPI-Referenz, bevor du das Anfragevolumen erhöhst.