Repository navigation
Building a bot
A bot is an account that a program runs. It joins a server as a member, reads what it is allowed to see, posts, and can offer slash commands. This page takes you from nothing to a working bot, then goes through what the API offers. Every request, call, event and field is listed in the API reference.
There are two kinds, fixed when the bot is made:
- An ordinary bot talks to the Maplecord server. This page is about these.
- A P2P bot connects straight to people's apps in P2P channels and can do nothing else. See P2P bots at the end.
If all you need is to post messages from another program, a webhook is simpler than a bot, and a bot can post with one request without staying connected.
- In the app, open Settings, then My bots.
- Type a name and press Create bot.
- Copy the Token and the Signing secret now. They are not shown again. Also copy the Bot ID.
When you make a bot, its ID, token and signing secret come up by themselves first, each with a Copy button.
The token is the bot's password. Keep it out of your code and out of anywhere public. If it leaks, press New token: the old token and signing secret stop working at once.
You can have up to 25 bots.
In Server settings, open Bots, paste the Bot ID and press Add bot. This takes the Manage server permission. Anyone can add a bot whose ID they have been given.
The bot joins as a member. What it may see and do is set with roles, like anyone else: it starts with what @everyone has, and you can give it more or less.
This bot answers !ping with pong, and has one slash command, /hello. It needs Node 22 or newer.
npm init -y
npm install @microsoft/signalrSave this as bot.mjs:
import { HubConnectionBuilder, HttpTransportType, LogLevel } from '@microsoft/signalr'
const server = process.env.MAPLECORD_SERVER // for example https://api.maplecord.app
const token = process.env.BOT_TOKEN // the token shown once under Settings, My bots
const botId = process.env.BOT_ID // the Bot ID shown under Settings, My bots
// Every request to the server carries the token like this.
async function api(path, init = {}) {
const res = await fetch(server + path, { ...init, headers: { Authorization: 'Bot ' + token, 'Content-Type': 'application/json' } })
if (!res.ok) throw new Error(`${init.method ?? 'GET'} ${path}: ${res.status} ${await res.text()}`)
return res.status === 204 ? null : res.json()
}
const me = await api('/api/me')
// Slash commands. This list replaces whatever the bot had before, in every server it is in.
await api(`/api/applications/${botId}/commands`, {
method: 'PUT',
body: JSON.stringify([
{ name: 'hello', description: 'Says hello', options: [{ name: 'name', description: 'Who to greet', type: 0, required: false }] },
]),
})
// The live connection: this is where messages arrive, and how the bot posts.
const meta = await (await fetch(server + '/api/meta')).json()
const hub = new HubConnectionBuilder()
.withUrl(server + '/hubs/chat', {
accessTokenFactory: () => 'bot:' + token,
...(meta.webSocketsOnly ? { skipNegotiation: true, transport: HttpTransportType.WebSockets } : {}),
})
.withAutomaticReconnect()
.configureLogging(LogLevel.Error)
.build()
// Every message in a channel the bot can see.
hub.on('MessageReceived', async message => {
if (message.authorId === me.id || message.kind !== 0) return // not the bot's own, and only ordinary messages
if (message.content.trim() === '!ping')
await hub.invoke('Post', { channelId: message.channelId, content: 'pong', replyToId: message.id })
})
// Someone used one of the bot's slash commands. It has 8 seconds to answer.
hub.on('InteractionCreated', async interaction => {
if (interaction.commandName === 'hello') {
const who = interaction.args.name ?? interaction.username
await hub.invoke('RespondToInteraction', interaction.id, interaction.token, { content: `Hello, ${who}!`, ephemeral: false })
}
})
hub.onreconnected(() => hub.invoke('Ready'))
await hub.start()
await hub.invoke('Ready')
console.log(`Signed in as ${me.username}`)Run it with the three values in the environment:
MAPLECORD_SERVER=https://api.maplecord.app BOT_TOKEN=... BOT_ID=... node bot.mjsThen type !ping or /hello in a channel the bot can see.
There are two halves, and most bots use both.
-
Requests for looking things up and for setting the bot up. They go to
https://api.maplecord.app/api/...with the headerAuthorization: Bot <token>. Bodies and answers are JSON. An error is{ "error": "..." }with a matching status code. -
A live connection for everything that happens as it happens: messages arriving, and the bot posting. It is a
SignalR connection to
/hubs/chat, with the access tokenbot:<token>. Microsoft publishes SignalR client libraries for JavaScript, .NET and Java, and there are others for more languages. After connecting, and after every reconnect, callReadybefore anything else. A Maplecord that is full keeps new arrivals in line, bots included. The three libraries below wait for you and tell you how many are ahead. On a connection of your own, askQueuePlacebeforeReady: see the API reference.
A message can be posted either way: with one request, or over the live connection. Editing, deleting and reacting are done over the live connection.
IDs are GUIDs. Field names are camelCase. Kinds and types are sent as numbers.
POST /api/channels/{channelId}/messages posts a message. Nothing has to stay connected, so it works from any
language, a scheduled job, or a terminal:
curl -X POST "https://api.maplecord.app/api/channels/<channel id>/messages" -H "Authorization: Bot <token>" -H "Content-Type: application/json" -d '{"content": "Backup finished"}'The answer is the message that was posted, with status 201. The body can hold:
| Field | |
|---|---|
content |
The text, up to 4000 characters |
replyToId |
The ID of a message this one answers |
threadId |
The ID of the message whose thread this goes in |
attachmentIds |
Pictures uploaded beforehand (see below) |
title |
For a post in a forum or a gallery, which needs one |
poll |
{ "options": ["Yes", "No"], "multi": false, "hours": 24 }, with content as the question |
buttons |
Up to five buttons under the message |
It follows the same rules as posting from the app: the bot needs Send messages in that channel, and it does not
work in a P2P channel. To find a channel's ID, read GET /api/guilds: each server there lists its channels.
A bot may post 10 messages in any 10 seconds, counting what it does on the live connection too. Past that the
answer is 429 with a Retry-After header.
| Request | Gives you |
|---|---|
GET /api/me |
The bot's own account |
GET /api/guilds |
Every server the bot is in, each with its channels, members and roles. Channels are already limited to the ones the bot may see. |
GET /api/guilds/{guildId} |
One server, the same way |
GET /api/channels/{channelId}/messages |
Messages, newest first. limit (50 unless given, 200 at most), and before, after or around a message ID. thread with a message ID reads that thread. |
GET /api/channels/{channelId}/messages/{messageId} |
One message |
GET /api/channels/{channelId}/pins |
Pinned messages |
GET /api/channels/{channelId}/search?q=... |
Up to 25 matching messages. One search at a time per account. |
GET /api/reactions |
The emoji that can be used as reactions |
A channel's type is 0 category, 1 text, 2 voice, 3 direct message, 4 forum, 5 gallery.
Call these on the live connection, for example hub.invoke('Post', {...}).
| Method | What it does |
|---|---|
SendMessage(channelId, content, attachmentIds) |
Posts a message. attachmentIds may be null. |
Post({ channelId, content, attachmentIds, replyToId, threadId, title, poll }) |
Posts with more choices: a reply, a message in a thread, a titled post in a forum or gallery, or a poll. Only channelId and content are needed. |
EditMessage(messageId, content, title) |
Edits one of the bot's own messages |
DeleteMessage(messageId) |
Deletes one of its own, or someone else's with Manage messages |
React(messageId, emoji, on) |
Adds or removes a reaction: one of GET /api/reactions, or one of the server's custom emoji written <:name:id>
|
Pin(messageId, on) |
Pins or unpins. Needs Manage messages. |
SetTyping(channelId) |
Shows the bot as typing |
RespondToInteraction(interactionId, token, { content, embeds, ephemeral, buttons, update }) |
Answers a slash command or a button press |
A message can be up to 4000 characters. Posting needs Send messages in that channel.
Where custom emoji are switched on, text can hold one of the server's own, written <:name:id> (the ids are in the messages people send). One the bot may not use there is refused with the reason. A sticker is posted on its own: Post({ channelId, stickerId }), with no text.
To post a picture, upload it first with POST /api/channels/{channelId}/attachments as multipart/form-data.
The answer has an id; pass it in attachmentIds. This needs Attach files. A bot can post pictures and no
other kind of file: anything else is refused with 400.
Register a handler for each event you care about with hub.on(name, handler). A bot only receives events for
channels it can see.
| Event | When |
|---|---|
MessageReceived(message) |
A message was posted in a channel |
ThreadMessage(message) |
A message was posted in a thread. These do not arrive as MessageReceived. |
MessageEdited(channelId, messageId, content, title, editedAt) |
A message was edited |
MessageDeleted(channelId, messageId) |
A message was deleted |
ReactionChanged(channelId, messageId, emoji, userId, on, count) |
A reaction was added or removed |
InteractionCreated(interaction) |
Someone used one of the bot's slash commands, or pressed one of its buttons |
MemberJoined(guildId, member), MemberLeft(guildId, userId)
|
Someone joined or left a server the bot is in |
ChannelCreated, ChannelUpdated, ChannelDeleted
|
A channel changed |
PermissionsChanged(guildId) |
What the bot may see or do there changed. Read the server again. |
The fields of a message you will use most: id, channelId, authorId, authorName, content, createdAt,
kind, attachments, replyTo, threadId, webhookId.
kind says what sort of message it is: 0 an ordinary message, 1 a system notice, 2 a roll, 3 a coin flip, 4 from a
webhook, 5 a bot's answer to a slash command, 6 rock paper scissors, 7 dice, 8 a file offer, 9 a call.
A bot that replies to messages should ignore its own (authorId is the bot's ID) and usually anything whose kind
is not 0, or it can end up answering itself or another bot for ever.
Send the whole list in one request. It replaces what was there.
-
PUT /api/applications/{botId}/commandsfor every server the bot is in. -
PUT /api/applications/{botId}/guilds/{guildId}/commandsfor one server.
[
{
"name": "weather",
"description": "Today's weather somewhere",
"options": [
{ "name": "city", "description": "Which city", "type": 0, "required": true },
{ "name": "units", "description": "Which units", "type": 0, "required": false,
"choices": [{ "name": "Celsius", "value": "c" }, { "name": "Fahrenheit", "value": "f" }] }
]
}
]Option type: 0 text, 1 whole number, 2 true or false, 3 a person, 4 a channel, 5 a number.
Limits: 50 commands for each of those two lists, 25 options a command. Names are lower-case letters, digits, _
and -, up to 32 characters. Descriptions are 1 to 200 characters.
Someone types /weather city:Paris in a channel. Values with spaces go in quotes: city:"New York". With no
name: in front, what they typed goes to the first required option, or to the first option if none is required.
Maplecord's own commands (/roll, /need, /dice, /flip, /rps and their relatives) come first, so do not give
a command one of those names. Slash commands work in server channels, not in direct messages or P2P channels.
Whoever manages a server can limit a bot's commands there: to certain roles and people, to certain channels, for the bot as a whole or one command at a time (Server settings, Bots, Manage). The bot does nothing for this. A use the server does not allow is refused before it reaches the bot, and people are only offered the commands they can use. Someone who can manage the server can always use all of them.
A bot can also watch the messages it receives for words of its own, such as !ping. That needs nothing
registered, and the server's limits on slash commands do not apply to it.
The bot receives InteractionCreated with:
id, token, commandName, guildId, channelId, userId, username, and args, the options as text keyed by
name. The server has already checked that required options are there and that each value fits its type.
Answer once with RespondToInteraction(id, token, { content, embeds, ephemeral }).
-
ephemeral: trueshows the answer only to whoever used the command, and it is not kept. -
embedstakes the same cards as a webhook; see below. - Answer within 8 seconds. After that the person is told the command did not get a response. A late answer is still posted for up to 15 minutes.
A bot does not have to stay connected. Under Settings, My bots, give the bot a web address to send its
slash commands to. When the bot is not connected, each use of a command is sent there as a POST with the same
fields as above, as JSON. The address has to be https and reachable from the internet.
Check that the request is really from Maplecord before trusting it. It carries two headers:
-
X-Maplecord-Timestamp: the time, in seconds. -
X-Maplecord-Signature:sha256=followed by the HMAC-SHA256, in lower-case hex, of the timestamp, a full stop and the exact body, keyed with the bot's signing secret.
import { createHmac, timingSafeEqual } from 'node:crypto'
function fromMaplecord(rawBody, headers, signingSecret) {
const expected = 'sha256=' + createHmac('sha256', signingSecret).update(headers['x-maplecord-timestamp'] + '.' + rawBody).digest('hex')
const given = headers['x-maplecord-signature'] ?? ''
return given.length === expected.length && timingSafeEqual(Buffer.from(given), Buffer.from(expected))
}Answer by sending { "content": "...", "ephemeral": false } to POST /api/interactions/{id}/{token}. That request
needs no Authorization header; the token in the address is what allows it, once.
A bot's message can have up to five buttons under it. When someone presses one, the bot is told who pressed which, and answers the way it answers a slash command.
Putting buttons on a message. Add buttons when posting, with one request or over the live connection, or
when answering a slash command:
{
"content": "Ready check for tonight",
"buttons": [
{ "id": "yes", "label": "I am ready", "style": 1 },
{ "id": "no", "label": "Not yet", "style": 2 },
{ "id": "later", "label": "Ask me later" }
]
}| Field | |
|---|---|
id |
The bot's own name for the button, handed back when it is pressed. 1 to 64 letters, digits, or any of _ . : -. No two on one message the same. |
label |
What the button says, 1 to 40 characters |
style |
0 plain, 1 in Maplecord's purple, 2 red. Plain if left out. |
When one is pressed the bot receives InteractionCreated, as for a slash command, with:
-
buttonId: theidof the button; -
messageId: the message it is under; -
userIdandusername: who pressed it; -
commandNameempty andargsempty, which is how to tell a press from a command.
hub.on('InteractionCreated', async interaction => {
if (interaction.buttonId === 'yes')
await hub.invoke('RespondToInteraction', interaction.id, interaction.token, { content: `${interaction.username} is ready.`, ephemeral: false })
})The bot answers once, within 8 seconds, with RespondToInteraction or POST /api/interactions/{id}/{token}. Its
answer is a new message, which can have buttons of its own. An answer with ephemeral: true is shown only to
whoever pressed, is not kept, and cannot have buttons.
Changing the message instead. With update: true in the answer, nothing new is posted: the message the button
is on becomes the answer, with its content, embeds and buttons. Buttons left out are taken off. Everyone
looking at the channel sees it change.
await hub.invoke('RespondToInteraction', interaction.id, interaction.token, { content: 'Added song 3.', buttons: [{ id: 'undo', label: 'Undo' }], update: true })If the message has been deleted in the meantime, the answer is posted as a new message.
Things to know:
- Buttons stay pressable for as long as the message is there. Pressing does not change the message unless the bot
answers with
update: true; if a button should stop counting after one use, the bot has to take it off that way or remember it itself. - Whoever may write in the channel may press. Only a bot's message can have buttons: not a person's, not a webhook's.
- A bot that is not connected is sent presses at its web address, like slash commands. With neither, the person is told the bot is offline.
- Presses count towards the person's 10 actions in 10 seconds, like messages.
A webhook is an address that posts a message into one channel when something sends JSON to it. Nothing has to stay running, and no bot is needed.
Make one: Server settings, Webhooks, type the name it will post as, choose a text channel and press Create webhook. Copy the address now; it is not shown again. This takes the Manage webhooks permission. New address replaces it if it leaks.
Post to it:
curl -X POST "https://api.maplecord.app/api/webhooks/<id>/<token>" \
-H "Content-Type: application/json" \
-d '{"content": "Build finished"}'What the body can hold:
| Field | |
|---|---|
content |
The text, up to 4000 characters |
username |
A name to post as this once, in place of the webhook's own |
avatarUrl |
A picture to post with this once |
embeds |
Up to 10 cards |
Either content or embeds has to be there. An embed is a card with any of:
{
"title": "Version 1.2.3",
"description": "All tests passed.",
"url": "https://example.com/builds/123",
"color": 3107839,
"fields": [{ "name": "Took", "value": "4 minutes", "inline": true }],
"image": { "url": "https://example.com/chart.png" },
"footer": { "text": "build server" }
}color is a number: the colour #2f6bff is 0x2f6bff, which is 3107839. A title can be 256 characters, a
description 2048, and a card can have 25 fields. Up to 4 pictures a message are shown; the Maplecord server fetches
each one and serves its own copy, so nobody's app loads anything from another site.
Limits: 15 webhooks a channel, and 30 posts a minute for each webhook. Past that the answer is 429.
| What | Limit | When you pass it |
|---|---|---|
| Requests | 600 a minute for each bot |
429, with a Retry-After header saying how many seconds to wait |
| Posting with a request, and posting, editing, reacting, pinning and voting over the live connection, all together | 10 in any 10 seconds for the bot, however many connections it has open | A request gets 429, with a Retry-After header; a call fails with "You are doing that too fast" |
| Other calls on the live connection | 800 in any 10 seconds on each connection | The same |
| Uploads | An hourly allowance for each account, and a largest file size the server sets |
429 or 413
|
| One message over the live connection | 128 KB |
Bots have the same limits as people. Build in a pause and try again when you are told to slow down.
A bot in more than 100 servers is no longer sent:
- messages, edits, reactions and typing. It cannot read messages back either: those requests get
403; - member lists, and people joining, leaving or changing.
GET /api/guildsthen lists only the bot itself as a member; - who is online, idle or not to be disturbed.
It still gets its slash commands and button presses, can answer them, and can post.
Whoever runs the Maplecord server sets that number, and can lift any of the three for one bot. Ask them if your bot needs one.
A bot joins voice channels only if its owner allows it: tick Let it join voice channels when you make it, or May join voice channels under My bots later. It also needs the Join voice permission, like anyone.
The two kinds of bot are in voice in different places, and very differently. Which kind you make decides all of this, and it cannot be changed afterwards.
| Ordinary bot | P2P bot | |
|---|---|---|
| Servers it can be in | Standard and hybrid servers | Full P2P and hybrid servers, and one server only |
| Voice channels it can join | Ordinary voice channels, and no P2P one | The P2P voice channels it was let into, one at a time, and no ordinary one |
| Being heard | It listens. It is heard only in turns it asks for, and only where whoever runs the Maplecord server has switched that on. | It is heard without asking, unless whoever runs the Maplecord server has switched P2P bots being heard off. Then it only listens. |
| Limits on what it sends | Seconds at a time, seconds of quiet after, and kilobits a second, all set by whoever runs the server. They can lift them for one bot. | None |
| Where its sound travels | Through the Maplecord server's relay or stream server, as a person's voice does there | Straight between the bot and each person's app. Never through the server or a relay. |
| What people are asked | Nothing. They see that it is a bot, and whether it is being heard. | Each person's app asks before it connects. Until they agree they do not hear it and it does not hear them. |
| IP addresses | It never learns anyone's | Whoever runs it can find the address of everyone who connects to it |
| Someone on the relay | Hears it like anyone else | Never connects to it, so does not hear it |
| Hearing people | It hears the channel | It hears whoever has connected to it |
| Sharing video | Only where whoever runs the server has switched video from bots on. It is off to start. | Only where they have switched video from P2P bots on, which is a separate switch. It is off to start. |
The same for both:
- A bot never watches video, and cannot start or join a call.
- People in the channel are told it is a bot.
- Whoever runs the Maplecord server can switch bots in voice off altogether.
A bot for music or anything else that plays for a long time is a P2P bot, where nothing limits what it sends because none of it crosses the server, or an ordinary bot that whoever runs the Maplecord server has lifted the limits for. A bot that speaks now and then in ordinary channels (a translator, a voice assistant) is an ordinary bot, within the limits the server sets.
An ordinary bot comes into a voice channel listening. It hears the channel and is not heard, and people see it marked that way. To be heard it asks, on the voice connection:
-
Speak()answers{ seconds, kbps, until, restSeconds }: how long the bot is heard for, the most kilobits a second it may send, when it will be made quiet again, and how long it then has to stay quiet. The server makes it quiet by itself when the time is up. - The apps of the people listening measure what arrives. A bot sending clearly more than
kbpsis no longer played, and its turn ends at once, with the whole quiet to follow. -
StopSpeaking()ends the turn early.
After a turn the bot has to stay quiet for restSeconds before it can ask again; asking sooner is refused and
says how many seconds are left. A turn ended early costs the same share of the quiet as was used of the turn: with
60 seconds of each, a turn ended after 5 seconds is followed by 5 seconds of quiet.
Whoever runs the Maplecord server decides whether bots can be heard at all, and sets the three numbers. It is off
unless they turn it on, and Speak() is then refused. They can also lift the limits for one bot: that bot is
heard from the moment it joins, Speak() answers { seconds: 0, kbps: 0, until: null }, and there is no turn to
keep to. The bot also needs the Speak permission in the channel.
A P2P bot in a P2P voice channel is heard without asking, and nothing it sends there goes through the server.
Whoever runs the server can switch that off, for every P2P bot or all but the ones they choose; a P2P bot is then
shown as listening, and people's apps play nothing from it. It is told in ParticipantUpdated, where its own
silenced is true.
Bots sharing video is off unless whoever runs the Maplecord server turns it on: one switch for ordinary bots in ordinary voice channels, another for P2P bots in P2P voice channels. They can also allow it for one bot. Where it is off, starting to share is refused. A bot never watches what others share. Nothing here sends video for a bot yet: no library does, and MapleLink sends sound only.
A bot is heard through MapleLink, which the libraries can drive: a P2P bot in a P2P voice channel, and an ordinary bot in an ordinary one. For an ordinary bot MapleLink plays only where the limits have been lifted for that bot: it does not take turns. The calls a bot makes itself to join a voice channel and ask to be heard are not in the API reference.
Whoever runs a Maplecord server can switch off, for every bot: bots as a whole, reading and posting messages, voice, P2P bots, and slash commands and buttons. For one bot, they can suspend it, ban it or revoke its token.
- Bots switched off, a P2P bot while P2P bots are off, or a suspended or banned bot: every request gets
401and its connections are closed. My bots says when a bot is suspended or banned. - A banned bot is also taken out of every server and cannot be added to one.
- A revoked token gets
401until you press New token. - Messages switched off: reading and posting get
403. Slash commands and buttons still reach the bot, and it can answer them.
- Be in a P2P channel, or read or post anything there.
- Watch video, or start or join a call.
- Press buttons.
- Use slash commands itself.
- Be someone's friend, be called, or be sent a direct message.
- Join a public server by itself. Someone with Manage server adds it.
- Make or manage other bots, link accounts, or wear a server tag.
If the person who made a bot is suspended, the bot stops working. If they delete their account, their bots go with it.
A P2P bot is made by ticking Make it a P2P bot when you create it. That cannot be changed afterwards.
- It can only be added to a server that has P2P channels (a full P2P server or a hybrid), and to one server only. For another server, make another bot. (An ordinary bot, the other way round, cannot be added to a full P2P server.)
- It is then let into P2P channels one at a time: right-click a P2P channel, open P2P bots and tick it.
- It connects straight to people's apps there. Each person is asked before their app connects to it, because whoever runs the bot can then find their IP address. It is never reached through the relay.
- It has no slash commands and cannot read or post in ordinary channels.
- People can type commands to it in the P2P channels it was let into, such as
!play. Those are ordinary messages that reach it straight from people's apps. See Typed commands below. - If its owner allows it, it can join the P2P voice channels it was let into. See Voice, which sets it beside an ordinary bot in an ordinary voice channel.
The server never sees what is typed to a P2P bot, so it cannot check who may use which command. Whoever manages a server can still set that in the app (Server settings, Bots, Manage), and the bot applies it:
- The bot asks which bot it is:
GET /api/applications/me. The answer'sidis its Bot ID. - It lists its commands by name:
PUT /api/applications/{botId}/commandswith[{ "name": "play", "description": "…" }, …]. No options: these are not slash commands, and are never offered after "/". - It reads what the server's managers set:
GET /api/applications/{botId}/guilds/{guildId}/command-rules. The answer hasbot(the rule for all of its commands),commands(each with aruleof its own, or null), andmanagers(the IDs of people who manage the server). - It reads them again when
CommandsChangedarrives.
A rule is { everyone, roleIds, userIds, allChannels, channelIds }. Someone may use a command when they are in
managers; or when the rule says everyone, names them, or names a role they hold (their roles are on the member
list from GET /api/guilds), and the channel is allowed. A command's own rule is used instead of the bot's.
Nothing makes a P2P bot keep to this but the bot. Only add one whose host you trust.
Writing one from scratch means speaking the same app-to-app protocol the Maplecord app does. The one P2P bot that exists today is the archive bot: see Keeping your own history.