Discord and Slack
Build a Chatbot: From API Call to Deployed Bot
Chapter 10 ยท Discord and Slack Bots
One adapter proves nothing about a design; three do. The web page and Telegram shared the same bot core, and this chapter adds Discord and Slack to test the claim that the core knows nothing about channels. The two platforms are very different from each other and from Telegram: Discord pushes events down a long-lived WebSocket that your bot has to keep alive, while Slack calls your web server and gives you three seconds to answer. Getting each right is mostly about their rules.
uvicorn receiving properly signed HTTP requests; the REST and Web API calls went to local stand-in servers. The project now has 241 passing tests (websockets 17.2, FastAPI 0.141.1). The incoming events are hand-written from the documented fields, not recorded. I read Discord's gateway,
message and rate-limit documentation and Slack's Events API, request-verification, Socket Mode and chat.postMessage pages. I could not read Discord's Developer Policy (the page refused access on both URLs I tried), and the Slack API Terms text itself; for Slack's 2025 terms changes I have only law-firm and news summaries. Both are flagged where they matter.
Same Core, Different Doors
| Telegram (ch. 9) | Discord | Slack | |
|---|---|---|---|
| How events arrive | You poll, or a webhook | You hold a WebSocket (the gateway) open | Slack calls your HTTPS endpoint (Events API); or Socket Mode, a WebSocket you open |
| Identity | Token in the URL | Token in the Authorization: Bot header and in the gateway's Identify | Bot token xoxb-โฆ as a bearer header; a signing secret to check incoming requests |
| Message size | 4,096 characters | 2,000 characters ("up to 2000 characters") | 4,000 recommended, over 40,000 truncated |
| When to answer in a group | Mention, /cmd@Bot, reply to the bot | Mention or reply to the bot (DMs always) | Mention (app_mention) in channels, anything in a DM |
| Conversation unit | The chat | The channel (a thread is a channel with its own id) | The DM, or each thread in a channel |
| Your deadline | None (but answer promptly) | Keep heartbeating or be disconnected | Acknowledge within 3 seconds or Slack retries |
| Pace | About 1 message a second per chat | 429 with retry_after; 50 requests a second per bot | About 1 message a second per channel |
Both new adapters reuse what already exists: split_message moved into a shared bot/channels/common.py, and the same patterns recur (ignore other bots, handle events in background tasks so one slow reply does not block another, never let one bad event stop the bot, wait as told on rate limits).
The core did not change at all.
Discord
What You Have to Know First
- Intents. When the bot connects it declares, as a bit mask, which kinds of events it wants. From the gateway documentation:
GUILDS= 1,GUILD_MESSAGES= 512,DIRECT_MESSAGES= 4096 andMESSAGE_CONTENT= 32768. The last one is privileged: you must also switch it on in the Developer Portal, and for verified apps it needs approval. Without it Discord sends messages with emptycontent(and embeds, attachments, components), except for messages the app sent, DMs to the app and messages that mention the app. Asking for a privileged intent that is not enabled makes Discord close the connection with code 4014. - The gateway protocol. Every frame is JSON
{"op", "d", "s", "t"}. The flow is:GET /gateway/botfor a URL, connect with?v=10&encoding=json, receive Hello (op 10) withheartbeat_interval, start heartbeating, send Identify (op 2) (token, intents, properties) and receive aREADYdispatch carryingsession_idandresume_gateway_url. Heartbeats (op 1) carry the last sequence number seen and must be acknowledged (op 11) before the next one is due; otherwise the connection is a "zombie" to close and resume. After a disconnect, Resume (op 6) with the session id and last sequence number replays missed events. Reconnect (op 7) asks you to reconnect; Invalid Session (op 9) says whether the session can be resumed. - Limits. Identify is limited to 1,000 a day (Resume does not count), so a reconnect loop must not be fast; 120 gateway events per 60 seconds per connection; 50 REST requests a second per bot; a 429 body carries
retry_after(seconds, a float); and an IP that causes more than 10,000 failed (401, 403, 429) requests in ten minutes is temporarily blocked. - Messages.
POST /channels/{id}/messageswith content "up to 2000 characters";message_referenceturns it into a reply;allowed_mentionscontrols who gets notified, and the docs say a regular message parses all mention types by default.
That last point matters for a bot whose text comes from a model: if the model writes "@everyone", Discord would treat it as a real mention. The REST client therefore sends allowed_mentions: {"parse": []} with every message, so nothing a model writes can notify anyone. A test checks it; in the end-to-end run below the reply
Ping @everyone and <@123> if you like. was sent with that setting.
| Intents sent | Value | What the bot sees in a server |
|---|---|---|
| Guilds + guild messages + direct messages | 4609 | Messages arrive, but content is empty unless the bot is mentioned (or it is a DM) |
โฆ plus MESSAGE_CONTENT | 37377 | Full content; requires the portal switch, or the gateway closes with 4014 |
Because this bot only answers mentions, replies and DMs, which Discord delivers with content even without the privileged intent, you could try 4609 first and avoid asking for the privileged intent at all. I have not tested that against Discord, so the adapter defaults to the full set and logs an
empty_content event when a mention or DM arrives with no text, which is the symptom of a missing intent.
The Code
The pieces to understand:
DiscordGatewayis the protocol: Hello, heartbeat with jitter, Identify or Resume, dispatch, zombie detection, Reconnect and Invalid Session handling. Whatever arrives is passed to a callback (adapter.dispatch).- Backoff. A session that never reaches
READYcounts as a failure, and the wait before the next try doubles (with jitter, capped at 60 seconds). A session that reachedREADYresets it. Close codes 4004 and 4010 to 4014 are treated as fatal, because retrying cannot fix a bad token or an unavailable intent. (The documentation page I read lists 4013 and 4014 and does not cover 4004, which comes from my memory.) DiscordAdapterignores other bots, webhooks and itself; answers DMs always and server messages only for mentions (thementionsarray) or replies to the bot (referenced_message, which Discord supplies for replies); strips the mention (<@id>or<@!id>) from the text; and replies to the original message in servers. Sessions are per channel id, and a thread has its own channel id, so threads are separate conversations, which is my understanding of Discord's model rather than something I tested.- No token in errors, as in chapter 9: a failed call raises a short error with the cause not chained.
Testing a Gateway Client Without Discord
The tests include a small gateway server of my own, built with the websockets library, that plays Discord's side from the documentation. Six scenarios, each asserting what the client sends:
| Scenario | What the test asserts |
|---|---|
| Normal start | The first frame is Identify with the token, intents = 37377 and the three properties; READY and a MESSAGE_CREATE reach the callback; heartbeats carry the last sequence number |
| Connection drops | The client reconnects and sends Resume with the session id and the last sequence number (2) |
| Invalid Session (false) | The client sends a fresh Identify |
| Server never acknowledges | The client declares a zombie, closes, and connects again |
| Close code 4014 | The client raises GatewayFatal after one connection and does not retry |
| Repeated failures before READY | Waits double (1, 2, 4, 8, 8, 8 seconds with a cap of 8); after a session that reached READY the wait resets (1, 2, then 1, 1) |
And Exercise 1 shows the two behaviours that matter most in practice, with real numbers: the intent values actually sent, and the difference between a transient close (1011: keeps trying, with growing waits of 0.05, 0.10, 0.20 and 0.40 seconds in the run) and the fatal 4014 (one connection, then an error).
An End-to-End Run
The real gateway client, adapter and bot against the fake gateway and a stand-in REST server. The fake gateway sends five messages: a DM, server chatter, a mention, a mention with empty content (what a missing intent looks like), and a DM that makes the model's reply contain a mention:
The DM is answered without a reply reference; the server chatter is ignored; the mention is answered as a reply to message m3 with the mention stripped from the text; the empty mention is logged as empty_content; and every outgoing message carries allowed_mentions: {"parse": []}.
Not Built: Slash Commands
Discord's modern way to talk to a bot is slash commands (/ask question:...), delivered as interactions, which must be acknowledged quickly and answered with a follow-up. A library-documentation source I saw earlier says slash-command-first bots often need less message-content access. I did not read Discord's interaction documentation for this chapter, so I have built nothing for it and make no claims about it; if you want your bot to avoid the privileged intent entirely, that is the route to research.
Slack
What You Have to Know First
- Two ways to receive events. The Events API: Slack sends an HTTPS request to a URL you register. Or Socket Mode: your app opens a WebSocket (using an app-level token,
xapp-โฆ, withapps.connections.open), so no public address is needed; the docs say it suits apps behind a firewall, allows up to 10 connections, requires you to acknowledge each envelope by itsenvelope_id, and that such apps cannot currently be listed in the public Slack Marketplace. I built the HTTP Events API because it needs no extra client and mirrors Telegram's webhook; Socket Mode is the choice when you cannot expose a public URL. - The three-second rule. Slack's documentation: respond with an HTTP 2xx "within three seconds" or the delivery counts as failed; return 200 as soon as you can and process separately. A failed delivery is retried up to three times (almost at once, after one minute, after five minutes), with
x-slack-retry-numandx-slack-retry-reasonheaders. A model call often takes longer than three seconds, so the route acknowledges immediately and does the work in the background, and the adapter ignores a repeatedevent_id. - Proving a request is from Slack. Every request carries
X-Slack-Request-TimestampandX-Slack-Signature. The recipe in the docs: reject if the timestamp differs from your clock by more than five minutes; buildv0:<timestamp>:<raw body>; compute HMAC-SHA256 with the signing secret; prefix the hex digest withv0=; compare with a constant-time function. It must use the raw body as received, not re-serialised JSON. - The first handshake. When you save the Request URL, Slack sends a
url_verificationrequest containing achallenge, and the app must answer with the same challenge. (The Events API page I read only mentions that you may encounterurl_verification; the echo is from my memory of how it works, and my tests encode that memory, so check it when you set up a real app.) - The event envelope. A callback has
type: "event_callback", a globally uniqueevent_id,team_id,api_app_idand the innerevent. The adapter handlesapp_mention(the scopeapp_mentions:readis documented for this) andmessageevents in a direct message (channel_type: "im"; the scope nameim:historyand this event shape are from my memory and not checked here). - Posting.
chat.postMessageneeds thechat:writescope. Docs: limittextto 4,000 characters for best results (over 40,000 is truncated); "generally 1 message per second to a specific channel";thread_tsmakes it a reply, and you should use the parent's timestamp;ratelimitederrors come with aRetry-Afterheader;not_in_channelmeans the bot has not been added to that channel.
The Code
Notes. verify_request takes the clock as an argument so it can be tested at the edges (exactly five minutes passes, a second more fails). The route reads await request.body() before parsing JSON. In a channel the adapter replies in a thread under the message that mentioned it, and each thread is its own conversation (slack:<channel>:<thread ts>), which keeps unrelated
questions from mixing and keeps the channel tidy; direct messages are one conversation per DM. Messages with a bot_id or any subtype (edits, joins) and the bot's own messages are ignored.
An End-to-End Run
The real route on real uvicorn, called with signed HTTP requests from httpx, and a model that takes 1.5 seconds per reply. The model is slow on purpose, to show the acknowledgement coming back long before the answer:
Read it top to bottom. The handshake echoes the challenge. An unsigned request and a correctly signed but hour-old request are both refused with 401. A signed event is acknowledged in about 0.2 seconds although the model needs 1.5, and the answer is posted afterwards. Slack's retry of the same event is acknowledged too but ignored (duplicate_event), so there were exactly two model calls and two posted messages:
the DM, and the channel mention answered in a thread (thread_ts=17.2, the mention's own timestamp).
Exercise 2 runs eight tamperings against the verification:
Socket Mode Instead
I did not build it. If you cannot expose a public URL, Socket Mode replaces the HTTP route with a WebSocket client, and the adapter's handle_callback() is unchanged because the payload is the same event envelope. You would add the acknowledgement of each envelope and reconnection (the same lesson as the Discord gateway: back off, and treat the URL as temporary because the docs say these URLs refresh regularly). Slack's own Bolt SDKs implement it for you.
The Rules You Agree To
| Platform | What I could establish |
|---|---|
| Telegram | Read in full in chapter 9: privacy policy, deletion, encryption at rest, no dataset-building or AI training from user data without explicit, revocable consent. |
| Discord | Not read. The Developer Policy page returned an access error from both addresses I tried, so I will not describe it. What I do know from the documentation: the Message Content intent is privileged and verified apps need approval. Read the Developer Policy yourself before you launch, looking in particular for rules about data retention and deletion, privacy policies, and use of message content with AI models. |
| Slack | I could not retrieve the API Terms. Secondary sources (law-firm and news coverage, not Slack's text) report that Slack revised its API Terms in 2025, for apps created from 29 May 2025 and older apps from 30 June 2025, to bar bulk export of API-accessible data, creating persistent copies or long-term stores of it, and using it in large language models, and that unlisted commercially distributed apps got much lower rate limits on conversations.history and conversations.replies (1 request a minute, 15 objects). The sources disagree about when existing installations are affected. |
The Slack point deserves a plain statement of why it matters here. This bot sends the text people write to Slack to a language model, and stores conversation history. Whether Slack's terms allow that for your kind of app (an internal tool in your own workspace is a different position from a commercially distributed one) is exactly the kind of question the revised terms may answer, and I cannot answer it for you. Read the current Slack API Terms and the developer policy, and ask someone qualified if the bot will serve other organisations. The same goes for Discord. These are engineering notes, not legal advice.
One Core, Three Platforms
Exercise 3 wires one Bot to all three adapters and sends a message through each:
Each platform's conversation is separate (the session ids start with the platform name) and so are the people: the same human on Telegram, Discord and Slack is three users to the bot, so a note stored through Telegram is not visible in the other two. That is the safe default. Linking identities across platforms needs a way to prove it is the same person, and it needs a decision about consent, so I have not built it.
- A wrong number in a comment. I wrote
# 50689beside the default intents. The real sum of 1 + 512 + 4096 + 32768 is 37377. The tests compare against the sum, so they passed; the end-to-end run printed the true value next to my "expected" and I noticed. The takeaway: a number in a comment is not tested; derive it in code, or check it. - My first Slack demo posted nothing, and the reason was hidden. The adapter's catch-all handler recorded
event_error:ProgrammingErrorand carried on, so the bot looked alive but silent. The cause: SQLite refuses to be used from a thread other than the one that created it, and my demo ran the web server in a second thread. I fixed the demo by creating the store withcheck_same_thread=False(the realrun_slack.pyruns everything in one thread and does not need it). Two lessons: a catch-all keeps the bot up but hides faults, so theeventslist must be watched (chapter 11 turns it into logs and alerts); and keep one thread unless you have a reason. - My reconnect loop would have burned Discord's daily allowance. The first gateway client waited a fixed second between attempts. Exercise 1's first version showed ten connections in 0.6 seconds against a server that kept refusing, and the documentation limits Identify to 1,000 a day. I added exponential backoff with jitter and tests for it. The takeaway: any reconnect loop needs backoff and a cap, and a limit stated in the docs should be checked against your retry behaviour.
- Discord's documentation had moved. The old
discord.com/developers/docsaddresses now redirect todocs.discord.com/developers. Links in older tutorials may be out of date.
What This Chapter Leaves Out
- Sharding (Discord's way of splitting a large bot over several connections; the connection data includes
shardsandsession_start_limit). One connection is fine for a small bot. - Slash commands, buttons, modals on both platforms; files, reactions, voice.
- OAuth installs. A Slack app that other workspaces install needs the OAuth flow; this bot is for one workspace.
- Formatting. Plain text only. Slack uses its own markup (
mrkdwn, on by default per the docs) and Discord uses a form of Markdown; a model's Markdown can look odd on either. - Everything about real tokens, real permissions and real servers. Starting scripts are provided (
run_discord.pyandrun_slack.py) and refuse to start without their tokens, which I ran; nothing beyond that was run.
Hands-On Exercises
With your own small fake gateway server, show the intents value sent in Identify with and without MESSAGE_CONTENT, then show what the client does when the server closes with a transient code (1011) and with 4014. Count connections and report the waits between attempts.
Compute a Slack signature yourself with hmac and hashlib, compare it with sign(), then run eight variations (wrong secret, body changed by one byte, re-serialised JSON, edited timestamp, 4-minute-old and 6-minute-old requests, missing header) through verify_request and explain each result.
Wire one Bot to the Telegram, Discord and Slack adapters with stub senders. Store a note through Telegram, then ask for the notes through Discord and Slack, and show the session ids and the notes per user id. Why do the other platforms not see the note, and what would linking identities require?
Chapter 10 Quick Reference
- Discord gateway: GET /gateway/bot, connect
?v=10&encoding=json, Hello (op 10), Identify (op 2: token, intents, properties) or Resume (op 6: token, session_id, seq), heartbeat (op 1, d = last sequence) acknowledged by op 11, dispatch (op 0), Reconnect (op 7), Invalid Session (op 9) - Intents: GUILDS 1, GUILD_MESSAGES 512, DIRECT_MESSAGES 4096, MESSAGE_CONTENT 32768 (privileged); 4609 without it, 37377 with it; close code 4014 = privileged intent not enabled (fatal)
- Identify limit 1,000 a day: back off on failure (doubling, jitter, cap), reset after a session that reached READY
- Discord messages: 2,000 characters, reply with
message_reference, always sendallowed_mentions: {"parse": []}; 429 body hasretry_after; REST token in theAuthorization: Botheader - Slack Events API: acknowledge 2xx within 3 seconds, work in the background, dedupe
event_id(retries after about 0, 1 and 5 minutes); verifyX-Slack-Signature=v0=+ HMAC-SHA256(secret,v0:ts:rawbody) with a 5-minute window; answerurl_verificationwith the challenge - Slack posting:
chat.postMessage(chat:write), 4,000 characters recommended, about 1 message a second per channel,thread_ts= the parent's ts, honourRetry-After - Sessions: DM or channel id (Discord), DM or thread (Slack); users are per platform
- Read before launch: Discord Developer Policy (not read here), Slack API Terms (only secondary summaries read; they report limits on bulk export, persistent copies and LLM use), Telegram Bot Developer Terms (read in chapter 9)
events lists you have seen in every chapter become real logs.