Zrzur Developers

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

What the bot does

CommandWhoShows
/pingeveryoneA private reply
/note addeveryoneA form; the note is saved to a file
/note listeveryoneNotes, with Older and Newer buttons
/warn member reasonmoderatorsA saved warning
/timeout member minutesmoderatorsA real timeout, with a deferred reply

Steps

1. Make an empty folder

mkdir my-bot
cd my-bot

2. 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.mjs

In 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:

  1. /ping answers Pong!, seen only by you.
  2. /note add opens a form. Fill it in and send it. The answer is Saved.
  3. A file bot-data.json is now in your folder.
  4. /note list shows the note. Add a second note, and the buttons work.
  5. /warn picks a member and a reason, and counts the warnings.
  6. /timeout silences a member. You cannot time out the server's owner: try it and read the error.
  7. Press Ctrl+C and start the bot again. Your notes are still there.
  8. Turn your network off and on. The terminal shows disconnected, then ready, 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 seeCauseFix
Set ZRZUR_BOT_TOKEN first.The variable is not set in this terminalStep 7
the token was refused, stoppingWrong or reset tokenCopy the token again, or reset it
could not start, ApplicationNotInstalledThe server id in ZRZUR_TEST_SERVER is wrong, or the bot is not thereStep 3 and 5
Commands do not appearThe bot is not in this serverStep 3
"I need the Timeout Members permission."The link had no permissionStep 3 again, or give the bot's role that permission
"That member ranks at or above me."The bot's role is too lowStep 4
"That member ranks at or above you."You rank at or below that memberAsk someone higher
"That member cannot be timed out."It is the server's owner (InvalidOperation)Pick another member
The bot did not answerThe bot was not runningStart it

Next