What a bot can do
A bot is a program you write. It connects to Anten like a member, reads and writes messages, and hears events (a new message, a reaction, someone joining a room) as they happen. The bot account is the program’s identity; the program connects with that account’s token.
A bot can:
- Offer commands: when people type
/in the message box, the bot’s commands are suggested. - Add cards, buttons and select menus to its messages, and answer a press so that only the person who pressed sees it.
- Read and write in encrypted rooms with keys of its own, as people’s apps do.
- Join a voice room and play sound.
If all you need is to post into a room from another system, use a webhook: no lasting connection, no library.
The examples use the anten-bot library. It comes with Anten’s files (the sdk/js folder), runs on Node.js
22.12 or later and needs no other packages. It keeps the connection open, reconnects when it drops, and takes care
of encrypted rooms by itself.
Your first bot
-
Make the bot.
Under Server settings → Bots, enter a name and press Make a bot. This needs the Manage bots permission in the first server; administrators have it.
-
Copy the token.
The token starts with
anten_bot_and is shown only once. Save it somewhere safe right away. -
Copy the library into the bot’s folder.
If Anten’s files are in an
antenfolder one level up:mkdir my-bot && cd my-bot cp -r ../anten/sdk/js anten-bot -
Write
bot.mjs.import { Client } from './anten-bot/src/index.js'; const bot = new Client({ host: process.env.ANTEN_HOST, // e.g. anten.example.org token: process.env.ANTEN_BOT_TOKEN, keyDir: '.anten-bot', // the bot's own keys }); bot.on('ready', () => bot.setCommands([{ name: 'ping', description: 'Checks that the bot answers' }])); bot.on('command', async ({ name, reply }) => { if (name === 'ping') await reply('pong'); }); bot.on('error', (err) => console.error(err)); bot.on('close', (code, reason) => console.error('stopped:', code, reason)); await bot.start(); -
Run the bot.
export ANTEN_HOST=anten.example.org export ANTEN_BOT_TOKEN=anten_bot_… node bot.mjs -
Try it.
Type
/pingin a room the bot can see. The bot answerspong.
The bot becomes a member of every server and joins the default groups. Which rooms it sees and where it may write you decide as for a person, with its groups and room permissions. Bots show a BOT tag next to their name. Set the bot’s picture in the same section with Change picture.
Commands
A bot announces the commands it offers with setCommands. When people type /, the commands of the bots that
can see the room and write there are suggested with their descriptions. Using a command sends an ordinary message:
in an encrypted room it stays encrypted, and the server never sees what it says. The library reads the message,
splits out the options and gives you a command event.
bot.on('ready', () =>
bot.setCommands([
{
name: 'roll',
description: 'Rolls a die',
options: [{ name: 'sides', description: 'How many sides', type: 'integer' }],
},
{
name: 'greet',
description: 'Greets someone',
options: [{ name: 'who', description: 'The person to greet', type: 'user', required: true }],
},
]),
);
bot.on('command', async ({ name, args, message, reply }) => {
if (name === 'roll') await reply(`🎲 ${1 + Math.floor(Math.random() * (args.sides ?? 6))}`);
if (name === 'greet') {
const member = bot.guilds.get(message.guild_id)?.members.get(args.who);
await reply(`Hello ${member?.user.display_name}!`);
}
});
Options
- Types:
string(text),integer(a whole number),number,boolean(yes/no, on/off, true/false) anduser(a member’s name or@name; the bot gets the user’s ID). - When a command’s last option is text (
string), it takes the rest of the line. Text with spaces may also be written in quotes. - Required options (
required: true) come first. - When a command is used with something missing or wrong, the library answers with its usage line, such as
⚠ /greet <who>. To answer yourself, listen for thecommandErrorevent. - When two bots offer the same command name, the suggestion reads
/roll@Helper(the bot’s name, without spaces).
Choices and autocomplete
You can give an option fixed values (choices); they are suggested while people type:
{ name: 'priority', description: 'How urgent', type: 'string', choices: [{ name: 'High', value: 'high' }, { name: 'Low', value: 'low' }] }
When the values are not known ahead, add autocomplete: true to the option. The bot is asked as the person types
and answers with up to 25 suggestions:
bot.on('autocomplete', async ({ command, option, query, choices }) => {
const cities = ['Istanbul', 'Izmir', 'Ankara'];
await choices(cities.filter((c) => c.toLowerCase().startsWith(query.toLowerCase())));
});
Buttons and menus
A message can carry cards (embeds), and buttons and select menus (components). They travel inside the message,
so in an encrypted room they are encrypted with it. The app draws them only on bots’ messages and loads no
pictures from elsewhere.
await bot.send(roomId, 'What shall we have today?', {
embeds: [{ title: 'Lunch', description: 'Pick one', color: 0x4fd1a5, fields: [{ name: 'Time', value: '5 min', inline: true }] }],
components: [
[
{ type: 'button', id: 'yes', label: 'Count me in', style: 'success' },
{ type: 'button', id: 'no', label: 'Not today', style: 'danger' },
],
[{ type: 'select', id: 'meal', placeholder: 'Choose', options: [{ label: 'Soup', value: 'soup' }, { label: 'Pasta', value: 'pasta' }] }],
[{ type: 'link', label: 'Menu', url: 'https://anten.example.org/menu' }],
],
});
- Button styles:
primary,secondary,success,danger. A link button (link) goes only to http(s) addresses. - A row holds up to 5 buttons or a single menu; a message holds up to 5 rows and 5 cards.
- A menu offers up to 25 options.
minandmaxask for more than one pick. - Change the message later with
bot.edit(message, text, { embeds, components }).
Interactions and replies
A press or a menu pick reaches the bot as an interaction. The bot answers it with reply. Only the person who
pressed sees the answer, and it is gone when the page reloads.
bot.on('interaction', async ({ customId, values, user_id, reply }) => {
if (customId === 'meal') await reply(`You picked: ${values[0]}`);
else await reply(customId === 'yes' ? 'You are on the list.' : 'Next time, then.');
});
Encrypted rooms
In an end-to-end encrypted room the server cannot read the messages. There the bot works with keys of its own, as
people’s apps do. anten-bot handles this, and the same code works in both kinds of room.
- On its first start the bot makes an identity key and a device key. It keeps both in
keyDir, in a file only its own user can read. - People’s apps in the room take the bot’s key on first sight and give it the room’s key too. When a new room key is needed, the bot makes it itself.
- The bot encrypts and signs its messages exactly as people’s apps do.
- A message not signed by its author’s known key comes with
trusted: false, and anidentityChangedevent is raised. Accept the new key withbot.acceptIdentity(userId, key)only after checking it with that person outside Anten.
keyDir is worth as much as a password: whoever gets it can read the rooms the bot can read. Back it up, but do not
share it or put it in a repository. If you lose it, the bot starts with a new identity and people in its rooms are
told that its key changed.
If the token or keyDir leaks, press Renew token: the bot’s devices are removed, the room keys it held are
withdrawn, and every room moves to a new key with its next message. The leaked keys can open old messages, but not
new ones.
Voice bots
A bot can join a voice room and play sound, end-to-end encrypted like people’s audio. The sound is sent by the
voice helper that comes with Anten’s files: the anten-voice program, written in Go, in the bots/voice folder.
It plays files and addresses through ffmpeg. The ready-made music bot works the same way. Build the program in
the bot’s folder:
go build -C ../anten/bots/voice -o "$PWD/anten-voice" .
The library’s VoiceHelper class starts it. Once connected (ready), the bot can join a room:
import { VoiceHelper } from './anten-bot/src/index.js';
const helper = new VoiceHelper({ path: './anten-voice', ffmpeg: 'ffmpeg' });
await bot.joinVoice(guildId, roomId, helper); // deaf by default
helper.play('music.ogg', { volume: 0.8 });
helper.on('ended', (error) => console.log('ended', error));
A bot joins deafened by default. This is more than a flag:
- The server sends a deafened bot nobody’s sound.
- People’s apps in the room give it no voice key; when a bot turns deaf, they move to a new key it does not get.
- If the bot undeafens (
{ deaf: false }), everyone sees it, and it receives the keys asvoiceKeyevents. The voice helper only sends; decoding what comes in is up to your code. - The server drops the sound of a muted bot (
bot.setVoice({ mute: true })).
Other languages
You can also connect without the library; for unencrypted rooms that is enough. In an encrypted room the server
refuses a plain message with a sealed_required error.
REST: Addresses look like https://anten.example.org/api/v1/.... Every request carries an
Authorization: Bot <token> header; bodies are JSON. IDs are strings in JSON. Errors look like
{"code": "...", "message": "..."}.
Gateway: Live events come from wss://anten.example.org/gateway, with the same header. Every frame looks like
{"op", "d", "s", "t"}.
-
Read the server’s first frame (
op10).d.heartbeat_intervalis the heartbeat interval in milliseconds. -
Identify.
Send
{"op": 2, "d": {}}. The server answers with aREADYevent holding the servers, rooms and members. -
Keep the heartbeat.
Send
{"op": 1, "d": <last s>}every interval; the server answers withop11. A connection whose heartbeat stops is closed with code 4009. -
Handle events.
Events come with
op0 and their name int:MESSAGE_CREATE,MESSAGE_UPDATE,MESSAGE_DELETE,MESSAGE_REACTION_ADD,GUILD_MEMBER_ADD,VOICE_STATE_UPDATE…
If the connection drops, wait a little and reconnect. Only after 4004 (token renewed or bot deleted), 4006
(kicked from the server) and 4007 (banned) do not reconnect. The bot’s own messages also arrive as
MESSAGE_CREATE: when the author is the bot itself, do not answer, or the bot ends up talking to itself.
A Python example that answers !ping (Python 3.10 or later, pip install websockets):
import asyncio, json, os, urllib.request
import websockets
HOST = os.environ["ANTEN_HOST"]
AUTH = {"Authorization": "Bot " + os.environ["ANTEN_BOT_TOKEN"]}
def api(method, path, body=None):
req = urllib.request.Request(
f"https://{HOST}/api/v1{path}", method=method,
data=json.dumps(body).encode() if body is not None else None,
headers={**AUTH, **({"Content-Type": "application/json"} if body is not None else {})},
)
with urllib.request.urlopen(req) as res:
return json.loads(res.read() or "null")
ME = api("GET", "/users/@me")
async def run():
while True:
try:
async with websockets.connect(f"wss://{HOST}/gateway", additional_headers=AUTH) as ws:
seq = 0
hello = json.loads(await ws.recv())
await ws.send(json.dumps({"op": 2, "d": {}}))
async def beat():
while True:
await asyncio.sleep(hello["d"]["heartbeat_interval"] / 1000)
await ws.send(json.dumps({"op": 1, "d": seq}))
beater = asyncio.create_task(beat())
try:
async for raw in ws:
f = json.loads(raw)
seq = f.get("s") or seq
if f["op"] == 0 and f["t"] == "MESSAGE_CREATE":
m = f["d"]
if (m.get("author") or {}).get("id") == ME["id"] or m.get("dm"):
continue
if m["content"].strip() == "!ping":
await asyncio.to_thread(api, "POST", f"/rooms/{m['room_id']}/messages",
{"content": "pong", "reply_to": m["id"]})
finally:
beater.cancel()
except websockets.ConnectionClosed as e:
if e.rcvd and e.rcvd.code in (4004, 4006, 4007):
raise SystemExit(f"closed: {e.rcvd.code} {e.rcvd.reason}")
await asyncio.sleep(5)
asyncio.run(run())
Limits and security
Bots can never reach these, even in the administrator group:
- Direct messages: a bot cannot read or write them, and nobody can send a bot one.
- Running the installation: access, backups, updates, server status and bots.
- Privilege keys, accounts and key backups.
So a leaked token cannot take over the installation. Other limits:
- An installation can have up to 50 bots.
- A bot offers up to 25 commands, each with up to 10 options, each option with up to 25 choices. Command names use
lower-case letters (Turkish letters included), digits,
_and-. - Write requests (messages, reactions, typing) may come 10 in a row, then 2 a second. More get
429: wait as many seconds as theRetry-Afterheader says. The library does this for you.
Good habits:
- Keep the token only on the machine the bot runs on, in an environment variable or a secrets store.
- Do not make the bot an administrator; put it in a group that sees only the rooms it needs.
- The bot decides who may use a command: check the person’s groups with
message.authorandbot.guilds.get(message.guild_id).members. - Never put text from people into a command line or an address as it is.
Reference
Client
new Client({ host, token, keyDir }) makes a bot; await bot.start() connects and await bot.stop() closes.
| Method | What it does |
|---|---|
send(roomId, text, { replyTo, embeds, components }) |
Sends a message, encrypted where the room is |
reply(message, text, extra) |
Answers a message |
edit(message, text, { embeds, components }) |
Changes one of the bot’s own messages |
delete(message) |
Deletes a message |
react(message, emoji) |
Adds a reaction |
typing(roomId) |
Shows “typing” for a few seconds |
messages(roomId, { before, limit }) |
A room’s messages, oldest first, opened |
setCommands(commands) |
Announces the bot’s commands |
setActivity({ kind, name }) |
Shows what the bot is doing next to its name; null clears it |
joinVoice(guildId, roomId, helper, { mute, deaf }) |
Joins a voice room |
setVoice({ mute, deaf }) |
Changes mute or deafen while in the room |
leaveVoice() |
Leaves the voice room |
acceptIdentity(userId, key) |
Accepts a new identity key after it was checked |
api(method, path, body) |
Sends a REST request |
bot.me is the bot itself, bot.guilds its servers (each with guild, rooms, members), and bot.voiceFlags
its voice state. For setActivity, kind is playing, listening or watching; the name is up to 64 characters.
Events
| Event | When |
|---|---|
ready |
The connection is up |
message, messageUpdate, messageDelete |
A message arrived, changed or was deleted (text is the opened text) |
reaction |
Someone reacted to a message |
command, commandError |
One of the bot’s commands was used, or used wrongly |
interaction, autocomplete |
A button was pressed, a menu pick made, or an option is being typed |
identityChanged |
A member’s identity key differs from the known one |
voice, voiceKey |
The bot’s voice room changed; a voice key arrived while not deafened |
event |
Every gateway event (its name and data) |
error, close |
Something failed; the connection closed for good |
REST
| Request | What it does |
|---|---|
GET /users/@me |
The bot itself |
GET /users/@me/guilds |
The bot’s servers |
GET /guilds/{guild} |
A server’s rooms, groups and members |
GET /rooms/{room}/messages?before={id}&limit=50 |
A room’s messages |
POST /rooms/{room}/messages |
Sends a message: {"content": "...", "reply_to": "{id}"} |
PATCH /rooms/{room}/messages/{id} |
Edits one of the bot’s own messages |
DELETE /rooms/{room}/messages/{id} |
Deletes a message |
POST /rooms/{room}/messages/{id}/reactions |
Adds a reaction: {"emoji": "👍"} |
POST /rooms/{room}/typing |
Shows “typing” |
PUT /users/@me/commands |
Announces the commands |
PUT /users/@me/avatar |
Changes the bot’s picture (body: the image) |