Docs / Ref. 01 aleja.app โ†—

Bot SDK reference

The bot SDK (bot-sdk/ in the Aleja repository) runs an end-to-end encrypted bot in Node.js. It generates the bot's keys on your machine, keeps them in a local state file, decrypts incoming messages, and encrypts everything the bot sends. See Bots for how this fits together and what bots are allowed to do.

Requirements

  • Node.js 22 or newer (for the built-in WebSocket, fetch and Web Crypto).
  • A checkout of the Aleja repository with dependencies installed (pnpm install at the root). The SDK uses the apps' own protocol code from src/lib, so a bot and the apps can never disagree about the encryption format.
  • A bot token from the developer console, with the gateway, e2ee and messaging scopes the bot uses.

Quick start

import { Bot, FileStore } from "./bot-sdk/src/index.js";

const bot = new Bot({
  token: process.env.ALEJA_BOT_TOKEN,
  server: "https://api.example.com",
  store: new FileStore("./my-bot.state.json"),
});

bot.on("message", async (message) => {
  if (message.text === "!ping") await message.reply("pong");
});

await bot.start();

The repository includes a runnable example:

cd bot-sdk
ALEJA_BOT_TOKEN=abot_... ALEJA_SERVER=http://localhost:8080 node examples/echo.js

On Windows PowerShell:

cd bot-sdk
$env:ALEJA_BOT_TOKEN = "abot_..."; $env:ALEJA_SERVER = "http://localhost:8080"; node examples/echo.js

Then add the bot to a nest under Nest settings โ†’ Apps โ†’ Integrations and send !ping, or type / and pick /ping or /roll from the bot's commands.

new Bot(options)

OptionDefaultMeaning
tokenrequiredThe bot token. Starts with abot_
serverhttp://localhost:8080The API base URL. https:// also switches the socket to wss://
storenew MemoryStore()Where keys and ratchet state live. Use a FileStore for any real bot
loggerconsoleAnything with warn, error and log methods, or null for silence
WebSocketImplglobalThis.WebSocketA WebSocket class, if you want a different one
fetchImplglobalThis.fetchA fetch function, for proxies or tests

Lifecycle

await bot.start()

Signs in, loads the bot's registered slash commands, loads or creates the bot's keys, publishes its public keys if the server doesn't have the current ones, collects any keys members sent while it was offline, and connects to the realtime socket. It rejects if the token is wrong, belongs to a person, or the state file belongs to a different bot.

await bot.stop()

Closes the socket, stops reconnecting, and waits until pending state has been written.

Reconnecting

If the connection drops, the bot reconnects with exponential backoff (1 to 30 seconds, with jitter). Before each attempt it checks the token. If the token was reset or the bot was deleted, it stops and emits error. After reconnecting it reloads its nests and collects any keys it missed.

Events

bot.on(name, handler) follows Node's EventEmitter. Handlers can be async; an exception thrown in one is emitted as error.

EventPayloadWhen
ready{ user, nests }The first time the socket is up. Not repeated on reconnects
messageMessageSomeone else posted in a channel the bot can see, or in a direct message with it
commandCommandSomeone ran one of the bot's slash commands
messageUpdateMessageA message was edited. text is the new text
messageDelete{ id, channelId }A message was deleted
undecryptable{ id, channelId, authorId, reason }A message couldn't be opened; see Limitations
nestAdded{ id, name, channels }The bot was added to a nest
nestRemoved{ id, name, channels }The bot was removed from a nest. Its keys for those channels are discarded
disconnect{ code, reason, wasOpen }The socket closed. A reconnect is already scheduled
reconnectThe bot reconnected
errorErrorSomething failed that the bot couldn't recover from on its own

The bot never receives its own messages as message events.

When a message arrives before its sender's key, the bot asks for the key and holds the message for up to two minutes. If the key arrives in time, the message is delivered as normal; otherwise it's reported as undecryptable.

Message objects

FieldTypeMeaning
idstringMessage id
channelIdstringChannel id
nestIdstring or nullThe nest, or null for a direct message
neststring or nullThe nest's name
isDirectbooleanTrue in a direct message
author{ id, username, name, bot }Who posted it. name is their nest nickname or display name. For webhook posts id and username are null
webhook{ id, name } or nullSet when a webhook posted it
textstringDecrypted text
filesarrayAttachments: { id, filename, mime, size, key, sha256 }
replyToobject or nullThe message this one replies to: { id, author_id, author, text }
commandobject or nullSet when the message runs a slash command: { bot_id, name, options }. For this bot's own commands you get a command event instead, with the options checked
stylestring"me" for a /me action, otherwise empty
ttsbooleanTrue for a /tts message. The server sets this after checking Send TTS
createdAtnumberMilliseconds since the epoch
editedAtnumber or nullWhen it was last edited
rawobjectThe message exactly as the server sent it (content still encrypted)

And three shortcuts:

  • await message.reply(text, options?) sends text to the same channel as a reply to this message.
  • await message.react(emoji) toggles the bot's reaction.
  • await message.download(file) downloads and decrypts one of message.files, resolving with a Uint8Array.

Slash commands

Register commands with setCommands, then handle the command event:

await bot.setCommands([
  { name: "ping", description: "Checks that the bot is listening." },
  {
    name: "roll",
    description: "Rolls a die.",
    options: [
      { name: "sides", description: "How many sides", type: "integer", required: true },
      { name: "for", description: "Who it's for", type: "user" },
    ],
  },
]);

bot.on("command", async (command) => {
  if (command.name === "ping") await command.reply("pong");
  if (command.name === "roll") {
    const result = 1 + Math.floor(Math.random() * command.options.sides);
    await command.reply(`You rolled ${result}`);
  }
});

await bot.start();

The definition format and limits are in Bots. The server checks them and setCommands rejects with an ApiError (400) that explains the first problem.

A command is an encrypted message that carries the invocation, so the bot needs the same access it needs for messages: it must be in the channel, and anything it misses (for example, before it has the sender's key) is reported as undecryptable.

The person's app builds the invocation, so the SDK checks it against your definitions first:

  • Options you didn't define, values of the wrong type, and values that aren't one of the choices are dropped.
  • If the command isn't one of yours, or a required option is missing or invalid, there's no command event. It arrives as a message whose text is the readable form, like /roll sides:20.

await bot.setCommands(definitions)

Replaces all of the bot's commands and resolves with them as the server stored them. It works before start(), and calling it with the same definitions again changes nothing. Pass [] to remove every command.

Command objects

FieldTypeMeaning
namestringThe command's name
optionsobjectChecked option values by name. user options are user ids. Optional options that weren't given are absent
user{ id, username, name, bot }Who ran it
channelIdstringWhere it was run
nestId, neststring or nullThe nest and its name, or null in a direct message
isDirectbooleanTrue in a direct message
messageMessageThe message that carried the command

And two shortcuts: await command.reply(text, options?) replies to the invocation, and await command.react(emoji) reacts to it.

Methods

await bot.send(channelId, text, options?)

Encrypts and posts a message. Returns { id, channelId }.

OptionMeaning
replyToA Message to reply to. The quote travels inside the encrypted message
filesUp to 5 attachments: { name, data, type? }, where data is a Uint8Array, Buffer or string. Each file is encrypted on your machine before upload, and its key travels inside the message
await bot.send(channelId, "Nightly report attached", {
  files: [{ name: "report.csv", data: csvText, type: "text/plain" }],
});

Uploads follow the server's file rules: allowed extensions and size limits apply as they do for members.

await bot.edit(channelId, messageId, text)

Replaces the text of one of the bot's messages. Attachments and reply quotes aren't kept.

await bot.delete(messageId)

Deletes a message. Bots can delete their own messages, and others' where their roles allow it.

await bot.react(messageId, emoji)

Toggles a reaction: the first call adds it, the second removes it.

bot.typing(channelId)

Shows the bot as typing in a channel for a few seconds. Returns false if the socket isn't connected.

await bot.download(file, message)

Same as message.download(file).

bot.listNests()

The nests the bot is in, each with { id, name, channels: [{ id, name, type }] }.

bot.fingerprint()

A digest of the bot's public identity key. It changes only if the bot gets new keys (for example, after its state file is lost), so it's useful to log at startup.

State storage

The store holds the bot's private keys and every ratchet. The SDK saves it before sending anything that used a fresh key, so a crash can't make the bot reuse one.

new FileStore(path)

Keeps state in one JSON file, created with permissions 0600 where the platform supports it. Writes go to a temporary file that's then renamed over the old one, so the file is never left half-written.

new MemoryStore()

Keeps state in memory. The bot gets a new identity every time the process starts, and members' apps switch keys each time. Fine for tests, not for real bots.

Custom stores

Any object with these two methods works, for example one backed by a secrets manager:

const store = {
  async load() { /* return the last saved object, or null */ },
  async save(state) { /* persist the object; resolve once it's durable */ },
};

state is a plain JSON-serializable object. Encrypt it at rest if your storage isn't private.

Errors

HTTP failures reject with ApiError, which has status and body:

import { ApiError } from "./bot-sdk/src/index.js";

try {
  await bot.send(channelId, "hello");
} catch (err) {
  if (err instanceof ApiError && err.status === 403) console.log("No permission to post there");
  else throw err;
}

Rate-limited requests (429) are retried automatically after the wait the server asks for. Failed reads are retried on network errors; writes are not, so a message is never posted twice.

Lower-level pieces

Engine (the encryption state machine), MissingKeyError and checkInvocation (the command check described under Slash commands) are exported for tests and custom transports. Most bots only need Bot and a store.