Bots
Tusile bots are members of your server that answer slash commands, put
buttons and forms on messages, moderate, and join voice channels. Add one somebody has already written
from Discover, or write your own in Node.js with the free
@tusile/bot npm package. It connects
out over a WebSocket, so a bot needs no public URL and no open port.
A bot is a member of your server. It has a name and an avatar, it shows up in the member list with a BOT tag, it holds roles, and its permissions are checked exactly like anybody else's. Giving it a role is how you decide what it may do, and it cannot read a channel you have not let it into.
It needs no public URL and no inbound port. The bot dials out: one WebSocket to each community it has been added to, and ordinary HTTPS requests back. You can run one on a laptop.
Two ways to get one
Use a bot somebody has already written. Open Discover → Bots in the app, pick one, and add it to your server. There is nothing to install and nothing to keep running: whoever made it runs it. This is the whole job for most people, and the rest of this page is not needed for it.
Write your own. A bot is a small program you run on a computer you leave switched on: a laptop, a spare machine, a cheap server. Making one in Settings → Bots gives it a name and two credentials and nothing else happens; it is online for as long as you are running the program. There is one that already works to start from.
Adding one to your server
- Open Server settings → Bots, or browse the listed bots in Discovery.
- Adding a bot needs Manage Server.
- You are shown what the bot says it needs, and you tick what you are willing to allow. It gets exactly that: turning off Send Messages means it cannot send messages.
- Change your mind later from the same screen. You can also give it a role, the way you would anybody else, which is how a bot gets more than it asked for.
- You cannot grant a permission you do not hold yourself, and you cannot grant one the bot never asked for.
- Removing it takes its commands with it.
What a bot can do
- Slash commands with typed options: numbers, switches, and pickers of the people, channels and roles on your server. A value the bot would refuse cannot be typed in the first place.
- Buttons, menus and forms. A bot can put controls on a message and ask you to fill something in.
- Answers only you can see. A private reply is not channel history: nobody else ever sees it, and it is gone within a day.
- Embeds: titles, fields, images and a colour bar, instead of a wall of text.
- Moderation: grant and take roles, time somebody out, kick, delete messages, all within the permissions its roles give it.
- Voice: join a channel and appear there like anybody else.
Writing one
Create the bot in Settings → Bots, or in Discover → Bots, which opens the same screen. Anybody with a confirmed account can make one, up to ten per account. Give it a name and a picture. You get a key and a secret; the secret is shown once, and a lost one is rotated rather than recovered. A bot can be handed to another account later, by email address, from the same screen; its key and secret keep working, so it stays online across the handover.
The field under the description, How to talk to it, is one line drawn at the top of the bot's profile card, above its commands. Fill it in if your bot answers mentions rather than slash commands: there is nothing for that card to list, so without the line it says "this bot has no commands" and the person reading it is no wiser. Tusile's own support bot has no commands at all, and its line reads "Mention me and ask a question."
Start from a bot that runs
Rather than assembling one from the pieces below, download a bot that already works and edit it. One script asks for the key and secret, installs what it needs and starts it. You need Node.js; the script checks, and says so if it is missing.
- Unpack it. On Windows, double-click
setup.bat; on macOS or Linux, runchmod +x setup.sh && ./setup.sh. - It asks for the key and secret, writes them to
.env, installs the package and starts the bot. After that,npm startis the whole command. - The bot is online for as long as that command keeps running on your computer, and offline when you stop it. It is not typed into a channel and it is not uploaded anywhere: the only thing joining it to your server is the key and secret you gave it.
- Edit
bot.jsand restart to see your own commands. The rest of this page is what you can put in that file.
Or do it by hand: the same three steps are a package to install, a file to save, and a command that starts it.
npm install @tusile/botimport { Bot } from '@tusile/bot';
const bot = new Bot(); // reads CORE_SERVER_URL, BOT_KEY, BOT_SECRET
bot.command({ name: 'ping', description: 'Check the bot is awake' },
(i) => i.reply('pong'));
await bot.start();CORE_SERVER_URL=https://api.tusile.com BOT_KEY=... BOT_SECRET=... node bot.jsThat bot is live on every community that has added it, and joins new ones by itself. The package handles what nobody should have to write twice: a token minted for each community, and a fresh one whenever a community refuses the old one, reconnects with a backoff, heartbeats at whatever interval the server asks for, and registering the command list on every connect.
The package is on npm as
@tusile/bot. Everything below is the
protocol underneath it, for writing a bot in another language or reaching something the package has no
method for.
Commands
bot.command(
{
name: 'roll',
description: 'Roll a die',
options: [
{ name: 'sides', type: 'integer', description: 'Sides', min: 2, max: 100 },
{ name: 'secret', type: 'boolean', description: 'Only you see it' },
],
},
async (i) => {
const sides = i.options.sides || 6;
await i.reply(`d${sides}: ${1 + Math.floor(Math.random() * sides)}`, {
ephemeral: Boolean(i.options.secret),
});
},
);Option types are string, integer, boolean,
user, channel and role.
| Field | What it does |
|---|---|
name | lowercase, no spaces |
type | one of the six above |
description | shown beside the input |
required | required options must come before optional ones |
min, max | integers only |
choices | [{"name": "Shown", "value": "sent"}], string and integer only, at most 25 |
Who may run it. A command that acts on the server rather than answering a question
can say what the person running it must hold, with requiredPermissions next to its name (on the
wire, required_permissions in PUT /bot/commands). All of them are required, not any of
them, and an administrator bypasses the check. Somebody without them is refused, and the app does not offer them
the command at all. Without it, a command is open to everybody who can talk in the channel, whatever the bot
itself was allowed to do.
choices turns the option into a picker, so nobody can type a value the bot
will refuse. user, channel and role are pickers of the people,
channels and roles on that server, and the bot is handed an id.
Values arrive already checked against this schema: an integer is a number and within its bounds, a choice is one of the ones offered, an id is the right shape. A bad value is refused before the bot hears about it, with a message naming the option, shown to whoever typed it.
At most 25 options per command. Registering replaces that bot's whole set, and only that bot's: two bots may register the same command name, and the app says whose is whose.
Answering
A bot has ten seconds to say something, or the person who asked is shown "the bot did not respond". Deferring buys fifteen minutes, and their client waits that long for it.
await i.reply('everyone in the channel sees this');
await i.replyPrivately('only the person who asked sees this');
await i.defer(); // buys fifteen minutes
await i.followUp('still working'); // another message after the first
await i.editReply('done'); // rewrite what it already said
await i.deleteReply();
await i.showModal({ ... }); // ask them to fill something in
// Rewrite the message a button is on.
await i.update({ content: 'Confirmed', components: [] });
await i.acknowledge(); // a press that needs no visible change| Response type | What happens |
|---|---|
message | posted to the channel; everyone sees it |
ephemeral | reaches only the person who asked, and is not channel history |
defer | acknowledges now; answer with a followup within 15 minutes. "ephemeral": true says the answer will be private, which is the only chance to say so: the edit carrying it later has no type of its own |
modal | asks them to fill something in |
update_message | rewrites the message the control is on, in place |
defer_update | acknowledges a press with no visible change |
The last two answer a press, not a command, and they are what a control is mostly for. A confirm that removes its own buttons, a page of a list that turns, a toggle that redraws: all of them rewrite the message in place. Posting a new message instead moves the exchange to the bottom of the channel and leaves the old buttons above it still working.
An absent embeds or components leaves that part alone; an empty
list removes it, which is how a bot turns its own buttons off.
Whether an answer is private is the bot's decision and nobody else's. The app will not offer to publish a private one: a bot that wants an answer shared answers publicly, or puts a button on the private one that posts.
An interaction can be answered once. A second answer is a conflict, not a silent overwrite, and the package refuses it before it leaves the process.
The package defers for you if a command or form handler is still working after eight seconds, so a bot that is busy answering is never reported as one that did not respond. A press is left alone: rewriting the message a button sits on is only available before the interaction has been answered at all, so a slow press should defer and follow up rather than update.
Buttons and menus
await i.reply({
content: 'Delete this channel?',
components: [{
type: 'row',
components: [
{ type: 'button', style: 'danger', label: 'Delete', custom_id: 'del:yes' },
{ type: 'button', style: 'secondary', label: 'Keep', custom_id: 'del:no' },
{ type: 'button', style: 'link', label: 'Docs', url: 'https://tusile.com' },
],
}],
});
// Matched on the prefix, so both buttons land here and the
// id carries the answer.
bot.component('del', (i) =>
i.update({
content: i.customId === 'del:yes' ? 'Gone.' : 'Kept.',
components: [],
}),
);Rows only at the top level, at most 5 rows of 5. A menu takes a row to itself. Button
styles are primary, secondary, danger and link;
anything else draws as a plain button rather than not at all. A link button carries a
url and never reaches the bot, every other style carries a custom_id and
does.
A custom_id is yours. The server echoes it back verbatim, never parses it,
and never truncates it below 100 bytes, so encode whatever state you need in it. An exactly registered
id wins over a prefix, and the longest matching prefix wins, so a general handler cannot swallow presses
meant for a specific one.
{ type: 'select', custom_id: 'size', placeholder: 'Pick one',
min_values: 1, max_values: 2, options: [
{ label: 'Small', value: 's' },
{ label: 'Large', value: 'l', description: 'the big one' },
] }A menu's min_values and max_values are enforced on the way back
as well as declared: a submission with too few or too many choices, or the same choice twice, is refused
before the bot hears about it. So is a form submission carrying a field the form never declared, leaving
a required one blank, or longer than 4000 bytes. A bot need not check any of it.
What the server does check is that the control is really yours: a press names the message that carries it, and the message must be one this bot authored and must actually contain a control with that id. Without that, anybody who can post could name any id and have a bot act as though it had authored the control.
Buttons on a private answer, and a form opened in answer to a command, are not messages.
Those carry source_interaction_id instead, and only the person the answer was addressed to
can use them.
Forms
bot.command({ name: 'feedback', description: 'Send a note' }, (i) =>
i.showModal({
custom_id: 'feedback',
title: 'Tell us more',
fields: [
{ custom_id: 'subject', label: 'Subject', required: true },
{ custom_id: 'body', label: 'Anything else', style: 'paragraph' },
],
}),
);
bot.modal('feedback', (i) => i.replyPrivately(`Noted: ${i.fields.subject}`));The submission arrives keyed by each field's custom_id.
style: "paragraph" draws a multi-line box; anything else is one line.
Embeds
embeds: [{
title: 'Now playing', url: 'https://example.com/track',
description: 'a song', color: 5793266,
author: { name: 'Jukebox', icon_url: 'https://...' },
fields: [{ name: 'Length', value: '3:21', inline: true }],
image: { url: 'https://...' }, thumbnail: { url: 'https://...' },
footer: { text: 'queued by Ada' },
}]color is 24-bit RGB. Limits: 10 embeds per message, 25 fields per embed, 256
bytes of title, 4096 of description, 1024 per field value, and 6000 across every embed on one message.
Bytes, not characters, so Arabic or Polish text reaches the limit at about half the character count and
CJK at about a third. Only http and https urls.
A payload past a limit is refused with a message naming the field, when it is sent rather than when it is drawn: a payload that reaches the database breaks the channel for everybody who opens it until a human deletes the row.
Everything a bot can do
Each handler is given i.server, the community the interaction came from.
i.server.request(method, path, body) reaches anything not on this list.
| Reading | |
|---|---|
history(channelId, { limit, before }) | channel history; limit up to 100, before to page |
message(id) | one message |
channels() | the channels it can see |
members() | members with their roles |
member(userId) | one member with their roles, held for a minute so a permission check before every command does not re-read the whole community; member(id, { fresh: true }) skips the held copy, forgetMembers() drops it |
roles() | the server's roles |
serverInfo() | the server's name, icon, member and channel count |
commands() | the commands this bot has registered here |
| Talking | |
|---|---|
send(channelId, message) | content, embeds, components, reply_to_id, attachment_url |
editMessage(id, message) | edit its own |
deleteMessage(id) | its own, or anybody's with Manage Messages |
deleteMessages(channelId, ids) | up to 100 at once, with Manage Messages |
typing(channelId) | show it is working on something |
thinking(channelId) | shows the bot as thinking until you call the function it returns; it repeats for you, so it lasts through a long model call |
react(id, emoji), unreact(id, emoji) | add and remove its reaction |
pin(id), unpin(id) | with Manage Messages |
| Moderating and managing | |
|---|---|
addRole(userId, roleId), removeRole(...) | with Manage Roles |
timeout(userId, seconds, reason), clearTimeout(userId) | with Moderate Members |
kick(userId) | with Manage Members |
ban(userId, reason), unban(userId), bans() | with Manage Members |
createChannel, updateChannel, deleteChannel | with Manage Channels |
categories(), createCategory, updateCategory, deleteCategory | with Manage Channels |
channelPermissions(id), setChannelPermission, removeChannelPermission | with Manage Channels or Manage Roles |
createRole, updateRole, deleteRole | with Manage Roles |
setNickname(userId, name) | '@me' renames the bot itself, which needs nothing |
joinVoice(channelId, { speaking }), setVoiceState, leaveVoice | voice presence, with Connect; a live mic needs Speak |
voiceToken(channelId) | a media token, for publishing audio |
A bot cannot create a role above its own, or put a permission into a role that it does not hold itself. Without those two rules Manage Roles is Administrator in two requests.
Running a command needs View and Send in the channel, plus whatever its
requiredPermissions name; pressing a control the bot already
posted needs only View, so a bot's buttons still work in a read-only announcements channel. The bot needs
View there as well, and an interaction in a channel it has been denied is refused outright rather than
handing it that channel's id.
Permissions are the member's, not a bypass. It cannot act on another bot. Carrying audio
in voice needs a LiveKit client as well (@livekit/rtc-node, joining the room named
channel_<channel id>); the calls above are the presence half, which is what most bots
want.
Adding a reaction is idempotent: sending it twice leaves one reaction rather than toggling it off, because a retry after a timeout must not undo the thing it was retrying.
member() is a saving, not a source of truth about what somebody may
do. The held copy carries roles, so a role taken away a minute ago is still in it. It cannot
grant anybody anything, because the community checks every action against the real roles. What it can
do is let a bot offer somebody a thing the server then refuses, or act on its own judgement where the
bot is the only judge, which is most of what a moderation bot does. Pass { fresh: true }
when the answer is a decision rather than a label.
Who somebody is
A community's member list knows when somebody joined and nothing else. What a bot needs
to decide anything about a person is on Core, through bot.core.
const profile = await bot.core.profile(userId); // last_seen, is_guest, username, ...
const name = await bot.core.displayName(userId); // what to call them, remembered
const owner = (await bot.core.community(serverId))?.owner_id;| On Core | |
|---|---|
profile(userId) | the public profile, or null |
displayName(userId) | their name, cached for ten minutes, or null |
community(serverId) | a community's public row, which names its owner, or null |
request(method, path, body) | anything else on Core, with the bot's own credentials |
These three answer null rather than throwing, unlike everything else in the
package: a bot sweeping two hundred members must not lose the pass to one lookup, and somebody it knows
nothing about is somebody to leave alone. displayName returns null rather than
a word like "someone", because that word belongs to your bot, in its own language.
Knowing the owner matters more than it looks. A bot holding Manage Members can remove the owner of the community that installed it: the kick route checks the permission and whether the target is a bot, and nothing else.
A bot that sets a server up
Everything a server is made of is reachable, so the first bot most people want to write is the one that builds the whole thing from a template.
import { Bot, Permissions } from '@tusile/bot';
bot.command({
name: 'setup', description: 'Build the standard channels',
// Only somebody who could do this by hand may ask the bot to.
requiredPermissions: ['manage_channels', 'manage_roles'],
}, async (i) => {
await i.defer();
const s = i.server;
const staff = await s.createRole({ name: 'Staff', permissions: Permissions.view_channel });
const team = await s.createCategory({ name: 'Team' });
const general = await s.createChannel({ name: 'general', type: 'text', category_id: team.id });
const inner = await s.createChannel({ name: 'staff-only', type: 'text', category_id: team.id });
// The part that makes it a server rather than a pile of channels.
const everyone = (await s.roles()).find((r) => r.is_default);
await s.setChannelPermission(inner.id, { role: everyone.id, deny: 'view_channel' });
await s.setChannelPermission(inner.id, { role: staff.id, allow: 'view_channel' });
await s.addRole(i.userId, staff.id);
await s.send(general.id, 'Set up. #staff-only is for the Staff role.');
await i.editReply('Done.');
});allow and deny take a permission name, a list of names, or a
number: view_channel, send_messages, manage_messages,
manage_channels, manage_roles, manage_members,
administrator, connect, speak, mute_members,
deafen_members, manage_server, create_invite,
attach_files, stream, use_camera, manage_emojis,
manage_emoji_policy, timeout_members. A name the package does not know
throws, rather than quietly denying nothing.
Two rules the server keeps whatever the bot asks for: it cannot create a role above its own, and it cannot allow, in a channel, a permission it does not hold itself. Denying is not capped, so a bot locking a channel down does not need every permission it is switching off.
Saying what your bot needs
Whoever adds your bot is shown the permissions it asks for and decides what to allow. Declare them in the bot, next to the commands that need them, and they travel with every release:
const bot = new Bot({
permissions: ['view_channel', 'send_messages', 'manage_channels', 'connect', 'speak'],
});A declaration grants nothing by itself. It is the list in the dialog, and the bot ends
up with what the person ticked and nothing more, so asking for less is a bot more people will add. The
names are the ones in Permissions; an unknown name throws at startup rather than quietly
dropping out of the list. Your bot’s owner can also tick the same list in
Settings → Bots, and what the bot declares in code wins when it starts.
Ask for view_channel if your bot does anything at all in a channel. It
is the floor: a bot that cannot see a channel is not handed the interactions that happen there, so a
bot that asks only for send_messages will be added, will look installed, and will answer
nothing.
A bot learns what it was actually granted when it connects, so it can say “I was not given Speak” instead of retrying a call that will always be refused.
Playing audio
A bot can be heard, not only seen. joinVoice puts it in the channel; the
audio goes through LiveKit, the same path the app uses.
npm install @livekit/rtc-nodeawait i.server.joinVoice(channelId, { speaking: true });
const { token, url } = await i.server.voiceToken(channelId);
const room = new Room();
await room.connect(url, token, { autoSubscribe: false });
const source = new AudioSource(48000, 1);
const options = new TrackPublishOptions();
options.source = TrackSource.SOURCE_MICROPHONE;
await room.localParticipant.publishTrack(
LocalAudioTrack.createAudioTrack('tone', source), options);
// 10 ms frames of signed 16-bit samples at 48 kHz.
await source.captureFrame(new AudioFrame(samples, 48000, 1, 480));Which channel: i.userVoiceChannelId is the voice channel the person who
ran the command is sitting in, so a /play can use the room they are already in instead of
asking them to name one. It is also the one channel id a bot can act on without wondering whether the
caller had any business naming it. It is '' when they are in none, and also from a
community too old to send it, so read '' as "they did not say".
speaking: true is what the channel shows. Without it the bot is drawn
with the crossed-out microphone while it plays, which is right for a bot that only listens and wrong
for one making a sound. It needs Connect to be in the channel and
Speak to claim a live microphone: presence is a promise to the room that it can be
heard, so the server refuses the claim without it.
A bot that plays a file decodes it to the same frames: 48 kHz, signed 16-bit, mono or stereo. Nothing in Tusile transcodes for you.
One thing about containers, because it costs an afternoon to find: Node carries its own
copy of the root certificates, but @livekit/rtc-node is Rust and reads the operating
system trust store. On a slim or Alpine base image there is no trust store, so the bot starts, joins,
answers and then fails the first time somebody asks it to play something, with
no native root CA certificates found. Install ca-certificates in the image. The
module also ships prebuilt binaries for glibc only, so Debian rather than Alpine.
Events
bot.on('ready', (me, server) => {}); // connected to a community
bot.on('message', (message, server) => {}); // said in a channel the bot can see
bot.on('joined', (server) => {}); // added to a community
bot.on('left', (serverId) => {}); // removed from one
bot.on('error', (err, context) => {}); // anything that went wrongA bot that replies to plain messages must check
message.author_id !== bot.me.id first, or it answers itself, forever.
Rate limits
Per bot per community: 60 writes a minute with a burst of 20, and 300 reads a minute with a burst of 60. A bot on many communities gets that allowance on each of them, not one between them.
Every response carries X-RateLimit-Limit,
X-RateLimit-Remaining and X-RateLimit-Reset (seconds until the budget is full),
so a bot can slow down before it is refused rather than after. Over the limit is a 429 with
Retry-After, which the package waits out and retries.
The protocol underneath
For writing a bot in another language. Two hosts: Core holds the bot's identity, each community holds its own data.
Authorization: Bot base64(key:secret) # to Core, only ever to Core
GET /bots/me # id, name, avatar
GET /bots/me/servers # [{server_id, api_url}]
# ?wait=25 holds it open
POST /bots/token {"server_id": "..."} # a token for one community,
# good for an hour
Authorization: Bearer <that token> # to that community, and nowhere else
WS /bot/gateway # send the auth frame first
PUT /bot/commands # the command list, every connect
POST /bot/interactions/{id}/callback # answer, within ten seconds
POST /bot/interactions/{id}/followup # again, within fifteen minutes
PATCH,DELETE /bot/interactions/{id}/response
POST /bot/channels/{id}/messages # and the rest of the table aboveThe gateway sends ready (with heartbeat_interval_seconds, which
a bot must honour or be dropped), interaction_create when somebody runs a command or presses
a control, and message_create for messages in channels the bot can see. A close reason of
bot_uninstalled means the bot was removed: stop reconnecting on that one, and retry
everything else with a backoff. server_retired means the community has left that box,
usually for a host of its own: ask Core for the server list again before reconnecting, because a
community keeps its id when it moves and only its api_url changes. A bot that reconciles
its connections on the id alone never notices the move, and reads as offline there until it is
restarted.
{
"type": "interaction_create",
"data": {
"id": "...", "type": "command", "command": "roll",
"options": {"sides": 20, "secret": true},
"channel_id": "...", "user_id": "...",
"custom_id": "", "values": [], "fields": {},
"message_id": "", "source_interaction_id": "",
"user_voice_channel_id": "..."
}
}A per-community token means a community never sees a credential that works anywhere else, and it expires in an hour, so mint a fresh one on every connect rather than working out whether the last one is still good.
Two things worth knowing
A bot is a member. It occupies a seat in the member list, it counts towards the server's member count (which decides discovery, never push or who can join), and removing it takes its commands with it. It is not a special case with its own permission rules.
Nothing is retried for you. If a bot is offline when somebody runs its command, the interaction times out and the person is told so. There is no queue and no replay: a bot that must not miss anything should stay connected, which is what the package does. A reconnect starts clean, so anything that happened while the socket was down is gone.
What this does not have
Said plainly, because finding out by getting a 404 is worse.
| Not there | Why |
|---|---|
| Direct messages | A bot cannot DM anybody. Tusile's one-to-one messages can be end-to-end encrypted, and a bot has no key for them. |
| Autocomplete on options | An option's choices are fixed when the command is registered; there is no per-keystroke callback. |
| Context-menu commands | No right-click-on-a-message or right-click-on-a-person commands, only slash commands and controls. |
| A status line | A bot cannot set a "playing" line. It does show as online while its gateway is connected, which is what the dot in the member list means. |
| Gateway resume | A reconnect does not replay what was missed. |
| Webhooks | Deliberately gone. It is what this replaced, and the reason a bot now needs no public address. |
Questions about Tusile bots
Does Tusile support bots?
Yes. Any server can add bots, and anybody with a confirmed account can make up to ten. A bot is a member with a BOT tag: it holds roles, and its permissions are checked like anybody else's.
Do I need a public URL or an open port to run a bot?
No. A bot dials out: one WebSocket to each community it has been added to, and ordinary HTTPS requests back. It can run on a laptop, a spare machine or a cheap server, and it is online for as long as the program is running.
Which language can I write a Tusile bot in?
The package, @tusile/bot, is for Node.js, and there is a starter bot to download and edit. Any other language works through the protocol described on this page: HTTPS to Core for a token per community, then a WebSocket gateway to each community.
How do I add a bot to my server?
Open Server settings, Bots, or browse the listed bots in Discovery. Adding a bot needs Manage Server, and the bot gets exactly the permissions you tick, never one you do not hold yourself.
Can a bot send direct messages?
No. One-to-one direct messages on Tusile can be end-to-end encrypted, and a bot has no key for them. A bot can answer privately in a channel instead, with a reply only you can see.
What are the rate limits for bots?
Per bot per community: 60 writes a minute with a burst of 20, and 300 reads a minute with a burst of 60. Over the limit is a 429 with Retry-After, which the package waits out and retries.
Where can I ask about writing a bot?
In the official Tusile community at app.tusile.com/j/tusile.