Build a complete bot
In about 30 minutes you have a real bot: slash commands, buttons, a form, moderation, saved data, plain error messages, and a connection that comes back by itself.
You will need
- Node 22 or newer. Check with
node --version. Nothing else: no library to install. - A Zrzur account and a server of your own to test in. Create the server in the app.
- Intents:
SERVERSandINTERACTIONS. Both are free, and the code asks for them.
What the bot does
| Command | Who | Shows |
|---|---|---|
/ping | everyone | A private reply |
/note add | everyone | A form; the note is saved to a file |
/note list | everyone | Notes, with Older and Newer buttons |
/warn member reason | moderators | A saved warning |
/timeout member minutes | moderators | A real timeout, with a deferred reply |
Steps
1. Make an empty folder
mkdir my-bot
cd my-bot2. Create the application
Open the Developer Portal, choose New application, enter a name, accept the bot rules and choose Create. Copy the token: it is shown once. See Applications and tokens.
3. Install the bot in your server, with Timeout Members
Open the tab OAuth2 of your application. In URL generator, tick bot.install, and under Bot permissions tick Timeout Members. Open the Invite link, pick your server and confirm.
4. Move the bot's role up
A bot can only time out members who rank below it. In your server open Server settings → Roles and drag the role with the bot's name above the roles of ordinary members.
5. Find your server's id
The address of your server in the app is /server/SERVER_ID. Copy that id. While you test, commands go to this server only.
6. Save the code
Save the file below as full-bot.mjs in your folder.
// A complete Zrzur bot in one file: Node 22 or newer, nothing to install.
// Slash commands, buttons, a form, moderation, saved data, error handling
// and reconnects.
//
// ZRZUR_BOT_TOKEN=YOUR_BOT_TOKEN node full-bot.mjs
import { randomUUID } from "node:crypto";
import { readFileSync, renameSync, writeFileSync } from "node:fs";
const TOKEN = process.env.ZRZUR_BOT_TOKEN;
const API = process.env.ZRZUR_API_URL ?? "https://api.zrzur.com";
const GATEWAY = process.env.ZRZUR_WS_URL ?? "wss://events.zrzur.com";
const DATA_FILE = process.env.DATA_FILE ?? "bot-data.json";
const TEST_SERVER = process.env.ZRZUR_TEST_SERVER; // optional, see register()
if (!TOKEN) {
console.error("Set ZRZUR_BOT_TOKEN first.");
process.exit(2);
}
/* ---------- Logging: one line of JSON per event, never the token ---------- */
const log = (level, message, fields = {}) =>
console.log(
JSON.stringify({
time: new Date().toISOString(),
level,
message,
...fields,
}),
);
/* ---------- Saved data: a JSON file, replaced in one step ---------- */
let data = { notes: {}, warnings: {} };
try {
data = JSON.parse(readFileSync(DATA_FILE, "utf8"));
} catch (error) {
if (error.code !== "ENOENT") throw error; // a broken file must stop the bot
}
function save() {
// write to a second file, then rename: a crash never leaves half a file
writeFileSync(`${DATA_FILE}.tmp`, JSON.stringify(data, null, 2));
renameSync(`${DATA_FILE}.tmp`, DATA_FILE);
}
/* ---------- REST: rate limits and retries in one place ---------- */
class ApiError extends Error {
constructor(status, body) {
super(`${status} ${body?.type ?? "error"}`);
this.status = status;
this.type = body?.type;
this.body = body;
}
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
/** An interaction's token is in its path: it must never reach a log */
const masked = (path) =>
path.replace(/(\/interactions\/[^/]+\/)[^/]+/, "$1***");
let globalPauseUntil = 0; // after a global 429 no request goes out
async function rest(method, path, body, headers = {}) {
for (let attempt = 0; attempt < 5; attempt++) {
const pause = globalPauseUntil - Date.now();
if (pause > 0) await sleep(pause);
let response;
try {
response = await fetch(API + path, {
method,
headers: {
"x-bot-token": TOKEN,
...(body ? { "content-type": "application/json" } : {}),
...headers,
},
body: body ? JSON.stringify(body) : undefined,
});
} catch (error) {
log("warn", "network error", {
method,
path: masked(path),
reason: error.message,
});
await sleep(2 ** attempt * 500);
continue;
}
const text = await response.text();
let parsed = null;
try {
parsed = text ? JSON.parse(text) : null;
} catch {
// a proxy in front of the API can answer with an HTML page
}
if (response.ok) return parsed;
// a 429 with a type (InSlowmode) is a refusal; without one, wait it out
if (response.status === 429 && !parsed?.type) {
const wait = parsed?.retry_after ?? 1000; // milliseconds
if (parsed?.global) globalPauseUntil = Date.now() + wait;
log("warn", "rate limited", {
path: masked(path),
wait,
global: !!parsed?.global,
});
await sleep(wait);
continue;
}
if (response.status >= 500) {
await sleep(2 ** attempt * 500);
continue;
}
throw new ApiError(response.status, parsed);
}
throw new Error(`${method} ${masked(path)}: gave up after 5 tries`);
}
/* ---------- Commands ---------- */
const COMMANDS = [
{
name: "ping",
description: "Check that the bot is awake",
description_localizations: {
ar: "تحقق من أن البوت يعمل",
de: "Prüfen, ob der Bot läuft",
},
},
{
name: "note",
description: "Notes for this server",
options: [
{ type: "SubCommand", name: "add", description: "Write a new note" },
{ type: "SubCommand", name: "list", description: "Read the notes" },
],
},
{
name: "warn",
description: "Warn a member",
// TimeoutMembers (bit 8): hidden from everyone who does not have it
default_member_permissions: 256,
options: [
{ type: "User", name: "member", description: "Who", required: true },
{ type: "String", name: "reason", description: "Why", required: true },
],
},
{
name: "timeout",
description: "Silence a member for a while",
default_member_permissions: 256,
options: [
{ type: "User", name: "member", description: "Who", required: true },
{
type: "Integer",
name: "minutes",
description: "How long",
required: true,
min_value: 1,
max_value: 10080,
},
],
},
];
async function register(me) {
// Global commands reach every server. While you test, set ZRZUR_TEST_SERVER
// to register in one server only. PUT replaces the whole list.
const path = TEST_SERVER
? `/applications/${me._id}/servers/${TEST_SERVER}/commands`
: `/applications/${me._id}/commands`;
await rest("PUT", path, COMMANDS);
log("info", "commands registered", { count: COMMANDS.length });
}
/* ---------- Answering interactions ---------- */
const answer = (interaction, type, extra = {}) =>
rest(
"POST",
`/interactions/${interaction._id}/${interaction.token}/callback`,
{ type, ...extra },
);
const reply = (interaction, content, ephemeral = true) =>
answer(interaction, "Message", { data: { content, ephemeral } });
// always read the *_string fields: a JavaScript number cannot hold every bit
const hasBit = (permissions, bit) => (BigInt(permissions) >> BigInt(bit)) & 1n;
const TIMEOUT_MEMBERS = 8;
/** Whether the person who asked ranks above the target; a lower number is higher */
async function outranks(interaction, targetId) {
const server = await rest("GET", `/servers/${interaction.server_id}`);
if (interaction.user._id === server.owner) return true;
if (targetId === server.owner) return false;
const rank = (member) =>
Math.min(
Infinity,
...(member?.roles ?? []).map(
(id) => server.roles?.[id]?.rank ?? Infinity,
),
);
const target = interaction.command.resolved?.members?.[targetId];
return rank(interaction.member) < rank(target);
}
/** The chosen sub-command and the values of a command, flat */
function optionsOf(command) {
const options = command.options ?? [];
const sub = options.find((option) => option.type === "SubCommand");
const values = Object.fromEntries(
(sub?.options ?? options)
.filter((option) => option.type !== "SubCommand")
.map((option) => [option.name, option.value]),
);
return { sub: sub?.name, values };
}
const notesOf = (serverId) => (data.notes[serverId] ??= []);
/** One note with Older and Newer buttons */
function notePage(serverId, index) {
const notes = notesOf(serverId);
const note = notes[index];
return {
content: `**${note.title}** (${index + 1} of ${notes.length})\n${note.text}`,
ephemeral: true,
components: [
{
type: "ActionRow",
components: [
{
type: "Button",
style: "Secondary",
label: "Older",
custom_id: `note:${index - 1}`,
disabled: index === 0,
},
{
type: "Button",
style: "Primary",
label: "Newer",
custom_id: `note:${index + 1}`,
disabled: index === notes.length - 1,
},
],
},
],
};
}
const NOTE_FORM = {
custom_id: "note-form",
title: "New note",
components: [
{
type: "Field",
label: "Title",
component: {
type: "TextInput",
custom_id: "title",
style: "Short",
max_length: 80,
},
},
{
type: "Field",
label: "Text",
description: "Everyone in this server can read it.",
component: {
type: "TextInput",
custom_id: "text",
style: "Paragraph",
max_length: 1000,
},
},
],
};
async function onCommand(interaction) {
const { command, server_id: serverId } = interaction;
const { sub, values } = optionsOf(command);
if (command.name === "ping") return reply(interaction, "Pong!");
if (command.name === "note" && sub === "add")
return answer(interaction, "Modal", { modal: NOTE_FORM });
if (command.name === "note" && sub === "list") {
if (!notesOf(serverId).length)
return reply(interaction, "No notes yet. Write one with `/note add`.");
const last = notesOf(serverId).length - 1;
return answer(interaction, "Message", { data: notePage(serverId, last) });
}
if (command.name === "warn") {
const key = `${serverId}:${values.member}`;
const list = (data.warnings[key] ??= []);
list.push({
reason: values.reason,
by: interaction.user._id,
at: Date.now(),
});
save();
return reply(
interaction,
`<@${values.member}> now has ${list.length} warning(s).`,
);
}
if (command.name === "timeout") {
if (!hasBit(interaction.app_permissions_string, TIMEOUT_MEMBERS))
return reply(interaction, "I need the Timeout Members permission.");
// this may take a moment: show "thinking" first, then edit the answer
await answer(interaction, "DeferredMessage", { data: { ephemeral: true } });
const original = `/interactions/${interaction._id}/${interaction.token}/original`;
if (!(await outranks(interaction, values.member)))
return rest("PATCH", original, {
content: "That member ranks at or above you.",
});
const until = new Date(Date.now() + values.minutes * 60_000).toISOString();
const who = interaction.member?.nickname ?? interaction.user.username;
await rest(
"PATCH",
`/servers/${serverId}/members/${values.member}`,
{ timeout: until },
// URL-encoded: a header cannot carry Arabic as it is
{ "X-Audit-Log-Reason": encodeURIComponent(`Requested by ${who}`) },
);
return rest("PATCH", original, {
content: `<@${values.member}> is silent for ${values.minutes} minute(s).`,
});
}
}
async function onComponent(interaction) {
const [kind, value] = interaction.component.custom_id.split(":");
if (kind === "note") {
const page = notePage(interaction.server_id, Number(value));
return answer(interaction, "UpdateMessage", { data: page });
}
}
async function onModal(interaction) {
if (interaction.modal.custom_id !== "note-form") return;
const field = (id) =>
interaction.modal.fields.find((f) => f.custom_id === id)?.value ?? "";
notesOf(interaction.server_id).push({
id: randomUUID(),
title: field("title"),
text: field("text"),
by: interaction.user._id,
});
save();
return reply(interaction, "Saved. Read it with `/note list`.");
}
/** Plain words for the person, the technical part for your log */
function explain(error) {
if (error.type === "MissingPermission")
return `I am missing a permission: ${error.body.permission}.`;
if (error.type === "NotElevated") return "That member ranks at or above me.";
if (error.type === "InvalidOperation")
return "That member cannot be timed out.";
return "Something went wrong. Please try again.";
}
async function onInteraction(interaction) {
const started = Date.now();
let acknowledged = false;
try {
if (interaction.type === "Command") {
acknowledged = interaction.command.name === "timeout";
await onCommand(interaction);
}
if (interaction.type === "Component") await onComponent(interaction);
if (interaction.type === "ModalSubmit") await onModal(interaction);
log("info", "interaction", {
kind: interaction.type,
name: interaction.command?.name,
ms: Date.now() - started,
});
} catch (error) {
log("error", "interaction failed", {
type: error.type,
reason: error.message,
});
const message = explain(error);
// answered already? edit that answer; if not, answer now
await (
acknowledged
? rest(
"PATCH",
`/interactions/${interaction._id}/${interaction.token}/original`,
{ content: message },
)
: reply(interaction, message)
).catch((failed) =>
log("error", "could not tell the user", { reason: failed.message }),
);
}
}
/* ---------- Gateway: stay connected ---------- */
const INTENTS = 1 + 32; // SERVERS + INTERACTIONS: nothing the bot does not use
let socket;
let failures = 0;
let stopping = false;
function connect() {
socket = new WebSocket(`${GATEWAY}/?version=1&format=json`);
let pingTimer;
let lastPong = Date.now();
socket.addEventListener("open", () => {
socket.send(
JSON.stringify({ type: "Authenticate", token: TOKEN, intents: INTENTS }),
);
pingTimer = setInterval(() => {
// no Pong for a while: the line is dead even if nothing says so
if (Date.now() - lastPong > 60_000) return socket.close();
socket.send(JSON.stringify({ type: "Ping", data: Date.now() }));
}, 20_000);
});
socket.addEventListener("message", ({ data: raw }) => {
const packet = JSON.parse(raw);
// several events can arrive in one Bulk packet
for (const event of packet.type === "Bulk" ? packet.v : [packet]) {
if (event.type === "Pong") lastPong = Date.now();
if (event.type === "Ready") {
failures = 0;
log("info", "ready", { servers: event.servers?.length ?? 0 });
}
if (event.type === "Error" && event.data?.type === "InvalidSession") {
log("error", "the token was refused, stopping");
process.exit(1);
}
if (event.type === "InteractionCreate") onInteraction(event.interaction);
}
});
socket.addEventListener("close", ({ code }) => {
clearInterval(pingTimer);
if (stopping) return;
if (code === 4014) {
log("error", "a privileged intent is off for this bot, stopping");
process.exit(1);
}
// 1 s, 2 s, 4 s ... 30 s, with a little randomness
const delay = Math.min(1000 * 2 ** failures, 30_000) + Math.random() * 500;
failures += 1;
log("warn", "disconnected", { code, retryIn: Math.round(delay) });
setTimeout(connect, delay);
});
}
function shutdown() {
stopping = true;
socket?.close();
log("info", "stopped");
process.exit(0);
}
process.on("SIGTERM", shutdown);
process.on("SIGINT", shutdown);
/* ---------- Start ---------- */
try {
const me = await rest("GET", "/users/@me");
await register(me);
connect();
} catch (error) {
log("error", "could not start", { type: error.type, reason: error.message });
process.exit(1);
}
7. Start it
export ZRZUR_BOT_TOKEN=YOUR_BOT_TOKEN
export ZRZUR_TEST_SERVER=YOUR_SERVER_ID
node full-bot.mjsIn Windows PowerShell, use $env:ZRZUR_BOT_TOKEN = "YOUR_BOT_TOKEN". On a self-hosted server, also set ZRZUR_API_URL and ZRZUR_WS_URL.
What you should see
In the terminal:
{"time":"2026-11-06T07:02:51.503Z","level":"info","message":"commands registered","count":4}
{"time":"2026-11-06T07:02:51.576Z","level":"info","message":"ready","servers":1}In your server, type / in a channel. The four commands appear with your bot's name. Then try them:
/pinganswersPong!, seen only by you./note addopens a form. Fill it in and send it. The answer isSaved.- A file
bot-data.jsonis now in your folder. /note listshows the note. Add a second note, and the buttons work./warnpicks a member and a reason, and counts the warnings./timeoutsilences a member. You cannot time out the server's owner: try it and read the error.- Press Ctrl+C and start the bot again. Your notes are still there.
- Turn your network off and on. The terminal shows
disconnected, thenready, and the commands work again.
How it works
Saved data. save() writes the whole data to a second file and renames it. A crash never leaves half a file. A JSON file is enough for one small bot; a real database fits a larger one, and only save() and the loading at the start change.
rest(). Every call goes through one function. It waits after a 429, pauses everything after a global one, retries on network errors and on 5xx, and throws ApiError for the rest. See Rate limits.
COMMANDS. The list is sent with PUT at each start. Without ZRZUR_TEST_SERVER the commands become global. See Slash commands.
optionsOf(). Values of a sub-command sit one level deeper than plain options. This function flattens them.
onCommand(). Each path gives one first answer: reply for a message, Modal for the form. /timeout first answers DeferredMessage, and later edits the original with PATCH. See Answering interactions.
custom_id. A button carries the page number in note:3. The bot holds no state for it, so buttons keep working after a restart.
hasBit(). default_member_permissions hides moderation commands from members, but the bot still checks its own permission in app_permissions_string first, to give a clear answer.
outranks(). The server only compares the target with the bot. Without this check a moderator could silence someone above them through the bot, so it compares the two people first. A lower rank number is higher.
onInteraction(). One try around each interaction. The person reads plain words from explain(), and your log has the details. If the first answer already went out, the error edits it. A second first answer would fail.
connect(). The Ping timer finds dead lines. The close handler waits 1, 2, 4 ... 30 seconds and connects again, and Ready resets the wait. It stops on InvalidSession and on 4014, because retrying cannot help. See Gateway.
Common errors
| What you see | Cause | Fix |
|---|---|---|
Set ZRZUR_BOT_TOKEN first. | The variable is not set in this terminal | Step 7 |
the token was refused, stopping | Wrong or reset token | Copy the token again, or reset it |
could not start, ApplicationNotInstalled | The server id in ZRZUR_TEST_SERVER is wrong, or the bot is not there | Step 3 and 5 |
| Commands do not appear | The bot is not in this server | Step 3 |
| "I need the Timeout Members permission." | The link had no permission | Step 3 again, or give the bot's role that permission |
| "That member ranks at or above me." | The bot's role is too low | Step 4 |
| "That member ranks at or above you." | You rank at or below that member | Ask someone higher |
| "That member cannot be timed out." | It is the server's owner (InvalidOperation) | Pick another member |
| The bot did not answer | The bot was not running | Start it |