Anten
Türkçe

Developers

Writing bots

A bot is a program that connects to Anten like a member: it answers commands and buttons, and works in encrypted and voice rooms too. A first bot takes about 10 minutes.

For
Developers who write JavaScript
Time
About 10 minutes for a first bot
You need
Node.js 22.12+, Manage bots permission
You get
A bot that answers /ping

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

  1. 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.

  2. Copy the token.

    The token starts with anten_bot_ and is shown only once. Save it somewhere safe right away.

  3. Copy the library into the bot’s folder.

    If Anten’s files are in an anten folder one level up:

    mkdir my-bot && cd my-bot
    cp -r ../anten/sdk/js anten-bot
    
  4. 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();
    
  5. Run the bot.

    export ANTEN_HOST=anten.example.org
    export ANTEN_BOT_TOKEN=anten_bot_…
    node bot.mjs
    
  6. Try it.

    Type /ping in a room the bot can see. The bot answers pong.

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) and user (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 the commandError event.
  • 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. min and max ask 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 an identityChanged event is raised. Accept the new key with bot.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 as voiceKey events. 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"}.

  1. Read the server’s first frame (op 10).

    d.heartbeat_interval is the heartbeat interval in milliseconds.

  2. Identify.

    Send {"op": 2, "d": {}}. The server answers with a READY event holding the servers, rooms and members.

  3. Keep the heartbeat.

    Send {"op": 1, "d": <last s>} every interval; the server answers with op 11. A connection whose heartbeat stops is closed with code 4009.

  4. Handle events.

    Events come with op 0 and their name in t: 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 the Retry-After header 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.author and bot.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)