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.

What was run, and what was not
Neither adapter was connected to Discord or Slack: there are no tokens, no Discord application and no Slack workspace here. What ran, over real sockets: the Discord gateway client against a gateway server of my own that plays Discord's side as the documentation describes it, and the Slack route on real 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)DiscordSlack
How events arriveYou poll, or a webhookYou hold a WebSocket (the gateway) openSlack calls your HTTPS endpoint (Events API); or Socket Mode, a WebSocket you open
IdentityToken in the URLToken in the Authorization: Bot header and in the gateway's IdentifyBot token xoxb-โ€ฆ as a bearer header; a signing secret to check incoming requests
Message size4,096 characters2,000 characters ("up to 2000 characters")4,000 recommended, over 40,000 truncated
When to answer in a groupMention, /cmd@Bot, reply to the botMention or reply to the bot (DMs always)Mention (app_mention) in channels, anything in a DM
Conversation unitThe chatThe channel (a thread is a channel with its own id)The DM, or each thread in a channel
Your deadlineNone (but answer promptly)Keep heartbeating or be disconnectedAcknowledge within 3 seconds or Slack retries
PaceAbout 1 message a second per chat429 with retry_after; 50 requests a second per botAbout 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 and MESSAGE_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 empty content (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/bot for a URL, connect with ?v=10&encoding=json, receive Hello (op 10) with heartbeat_interval, start heartbeating, send Identify (op 2) (token, intents, properties) and receive a READY dispatch carrying session_id and resume_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}/messages with content "up to 2000 characters"; message_reference turns it into a reply; allowed_mentions controls 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 sentValueWhat the bot sees in a server
Guilds + guild messages + direct messages4609Messages arrive, but content is empty unless the bot is mentioned (or it is a DM)
โ€ฆ plus MESSAGE_CONTENT37377Full 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

import asyncio import json import random import re import httpx import websockets from bot.channels.common import split_message from bot.core import Bot from bot.providers import ProviderError MESSAGE_LIMIT = 2000 # Discord docs: "Message contents (up to 2000 characters)" API = "https://discord.com/api/v10" # Gateway intents (bit values from the Discord gateway documentation) GUILDS = 1 << 0 GUILD_MESSAGES = 1 << 9 DIRECT_MESSAGES = 1 << 12 MESSAGE_CONTENT = 1 << 15 # PRIVILEGED: switch it on in the Developer Portal as well as asking for it here DEFAULT_INTENTS = GUILDS | GUILD_MESSAGES | DIRECT_MESSAGES | MESSAGE_CONTENT # 37377 FATAL_CLOSE_CODES = {4004, 4010, 4011, 4012, 4013, 4014} # bad token, bad shard/intents, privileged intent not enabled: retrying cannot help class DiscordError(Exception): def __init__(self, description: str, *, status: int | None = None, retry_after: float | None = None) -> None: super().__init__(description) self.status, self.retry_after = status, retry_after class GatewayFatal(Exception): """The gateway closed the connection in a way that reconnecting cannot fix (for example a privileged intent is not enabled).""" class DiscordREST: """The few REST calls the bot needs. The token goes in the Authorization header, not the URL.""" def __init__(self, token: str, *, base_url: str = API, timeout: float = 15.0, transport: httpx.AsyncBaseTransport | None = None) -> None: self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout, transport=transport, headers={"Authorization": f"Bot {token}", "User-Agent": "DiscordBot (https://example.invalid, 0.1)"}) async def call(self, method: str, path: str, **json_body): try: response = await self._client.request(method, path, json=json_body or None) except httpx.HTTPError as e: raise DiscordError(f"{method} {path}: {type(e).__name__}") from None if response.status_code == 204: return None try: body = response.json() except ValueError: body = {} if response.status_code >= 400: raise DiscordError(body.get("message", f"HTTP {response.status_code}"), status=response.status_code, retry_after=float(body["retry_after"]) if "retry_after" in body else None) return body async def gateway_url(self) -> str: return (await self.call("GET", "/gateway/bot"))["url"] async def create_message(self, channel_id: str, content: str, reply_to: str | None = None): payload = {"content": content, "allowed_mentions": {"parse": []}} # the model's text must never be able to ping @everyone if reply_to: payload["message_reference"] = {"message_id": reply_to} return await self.call("POST", f"/channels/{channel_id}/messages", **payload) async def trigger_typing(self, channel_id: str): return await self.call("POST", f"/channels/{channel_id}/typing") async def aclose(self) -> None: await self._client.aclose() class DiscordAdapter: """Turns Discord MESSAGE_CREATE events into Bot calls. Sessions are per channel (a thread is a channel too).""" def __init__(self, bot: Bot, rest: DiscordREST, *, bot_user_id: str | None = None, typing_every: float = 8.0, send_gap: float = 0.5, sleep=asyncio.sleep) -> None: self.bot, self.rest, self.bot_user_id = bot, rest, bot_user_id self.typing_every, self.send_gap, self._sleep = typing_every, send_gap, sleep self.events: list[str] = [] self._tasks: set[asyncio.Task] = set() def dispatch(self, event_name: str, data: dict) -> None: if event_name == "READY": self.bot_user_id = data["user"]["id"] elif event_name == "MESSAGE_CREATE": task = asyncio.create_task(self.safe_handle(data)) self._tasks.add(task) task.add_done_callback(self._tasks.discard) async def drain(self) -> None: while self._tasks: await asyncio.gather(*list(self._tasks)) async def safe_handle(self, message: dict) -> None: try: await self.handle_message(message) except Exception as e: # noqa: BLE001 - one bad event must never stop the bot self.events.append(f"event_error:{type(e).__name__}") async def handle_message(self, message: dict) -> None: author = message.get("author") or {} if author.get("bot") or author.get("id") == self.bot_user_id or message.get("webhook_id"): self.events.append("ignored:bot_or_webhook") return in_guild = "guild_id" in message mentioned = any(u.get("id") == self.bot_user_id for u in message.get("mentions", [])) replied_to_us = ((message.get("referenced_message") or {}).get("author") or {}).get("id") == self.bot_user_id if in_guild and not (mentioned or replied_to_us): self.events.append("ignored:not_addressed") return content = re.sub(rf"<@!?{re.escape(str(self.bot_user_id))}>", "", message.get("content", "")).strip() if not content: # Without the privileged MESSAGE_CONTENT intent Discord sends empty content, except for DMs and mentions. self.events.append("empty_content" if (mentioned or not in_guild) else "ignored:empty") return channel_id = message["channel_id"] typing = asyncio.create_task(self._typing(channel_id)) try: answer = await self.bot.handle(f"discord:{channel_id}", content, user_id=f"discord:{author['id']}") except ProviderError as e: self.events.append(f"provider_error:{type(e).__name__}") answer = self.bot.error_reply finally: typing.cancel() await asyncio.gather(typing, return_exceptions=True) reply_to = message["id"] if in_guild else None for number, piece in enumerate(split_message(answer, MESSAGE_LIMIT)): if number: await self._sleep(self.send_gap) await self._send(channel_id, piece, reply_to if number == 0 else None) async def _typing(self, channel_id: str) -> None: while True: try: await self.rest.trigger_typing(channel_id) except DiscordError: pass await self._sleep(self.typing_every) async def _send(self, channel_id: str, text: str, reply_to: str | None) -> None: for attempt in range(1, 4): try: await self.rest.create_message(channel_id, text, reply_to) return except DiscordError as e: if e.retry_after is None or attempt == 3: self.events.append(f"send_failed:{e.status}") return await self._sleep(e.retry_after) class DiscordGateway: """The Discord gateway: a WebSocket on which Discord pushes events. Flow (from the gateway documentation): connect, receive Hello (heartbeat_interval), start heartbeating (the first beat after interval x random jitter, the rest every interval, each carrying the last sequence number), send Identify (token, intents, properties) or Resume (token, session_id, seq), then receive Dispatch events. If a heartbeat is not acknowledged before the next one is due the connection is a zombie: close it and resume.""" def __init__(self, token: str, gateway_url: str, on_dispatch, *, intents: int = DEFAULT_INTENTS, connect=websockets.connect, rng=random.random, reconnect_delay: float = 1.0, max_backoff: float = 60.0, sleep=asyncio.sleep, invalid_session_wait: float | None = None) -> None: self.token, self.url, self.on_dispatch, self.intents = token, gateway_url, on_dispatch, intents self._connect, self._rng, self.reconnect_delay = connect, rng, reconnect_delay self.invalid_session_wait = invalid_session_wait self.max_backoff, self._sleep = max_backoff, sleep self._got_ready = False self.session_id: str | None = None self.resume_url: str | None = None self.seq: int | None = None self.log: list[str] = [] # what happened, for tests and for chapter 11's logs self._stop = False def stop(self) -> None: self._stop = True async def run(self) -> None: """Keep a session alive. After a session that never got as far as READY, wait longer each time (exponential backoff with jitter, capped): every Identify counts against a limit of 1,000 per 24 hours, so a fast reconnect loop could use the day's allowance in minutes. A session that reached READY counts as healthy and resets the wait.""" failures = 0 while not self._stop: self._got_ready = False try: await self._session() except GatewayFatal: raise except (websockets.ConnectionClosed, OSError, asyncio.TimeoutError) as e: if isinstance(e, websockets.ConnectionClosed) and e.rcvd and e.rcvd.code in FATAL_CLOSE_CODES: raise GatewayFatal(f"closed with code {e.rcvd.code}: {e.rcvd.reason}") from None self.log.append(f"disconnected:{type(e).__name__}") failures = 0 if self._got_ready else failures + 1 if not self._stop: wait = min(self.max_backoff, self.reconnect_delay * 2 ** max(0, failures - 1)) self.log.append(f"wait:{wait * (0.5 + self._rng() / 2):.2f}") await self._sleep(wait * (0.5 + self._rng() / 2)) async def _session(self) -> None: url = (self.resume_url if self.session_id else self.url) + "?v=10&encoding=json" async with self._connect(url) as ws: hello = json.loads(await ws.recv()) assert hello["op"] == 10, "expected Hello" interval = hello["d"]["heartbeat_interval"] / 1000 acked = True if self.session_id and self.seq is not None: self.log.append("resume") await ws.send(json.dumps({"op": 6, "d": {"token": self.token, "session_id": self.session_id, "seq": self.seq}})) else: self.log.append("identify") await ws.send(json.dumps({"op": 2, "d": {"token": self.token, "intents": self.intents, "properties": {"os": "windows", "browser": "chatbot-course", "device": "chatbot-course"}}})) async def heartbeat(): nonlocal acked await asyncio.sleep(interval * self._rng()) while True: if not acked: # no ACK since the last beat: a zombie connection self.log.append("zombie") await ws.close(code=4000, reason="no heartbeat ack") return acked = False await ws.send(json.dumps({"op": 1, "d": self.seq})) await asyncio.sleep(interval) beat = asyncio.create_task(heartbeat()) try: async for raw in ws: frame = json.loads(raw) op = frame["op"] if op == 0: self.seq = frame["s"] if frame["t"] == "READY": self._got_ready = True self.session_id, self.resume_url = frame["d"]["session_id"], frame["d"]["resume_gateway_url"] self.on_dispatch(frame["t"], frame["d"]) elif op == 1: await ws.send(json.dumps({"op": 1, "d": self.seq})) elif op == 11: acked = True elif op == 7: self.log.append("reconnect_requested") return elif op == 9: self.log.append(f"invalid_session:{frame['d']}") if not frame["d"]: self.session_id, self.seq = None, None await asyncio.sleep(self.invalid_session_wait if self.invalid_session_wait is not None else 1 + 4 * self._rng()) # a short random wait (my reading of the docs; not re-verified) return if self._stop: return finally: beat.cancel() await asyncio.gather(beat, return_exceptions=True)

The pieces to understand:

  • DiscordGateway is 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 READY counts as a failure, and the wait before the next try doubles (with jitter, capped at 60 seconds). A session that reached READY resets 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.)
  • DiscordAdapter ignores other bots, webhooks and itself; answers DMs always and server messages only for mentions (the mentions array) 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:

ScenarioWhat the test asserts
Normal startThe 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 dropsThe 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 acknowledgesThe client declares a zombie, closes, and connects again
Close code 4014The client raises GatewayFatal after one connection and does not retry
Repeated failures before READYWaits 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).

intents without MESSAGE_CONTENT: 4609 | sent in IDENTIFY: 4609 intents with MESSAGE_CONTENT : 37377 | sent in IDENTIFY: 37377 close code 1011 (internal error (transient)): connections made = 4, result: still trying to reconnect, waits so far: 0.05, 0.10, 0.20, 0.40 seconds close code 4014 (privileged intent not enabled): connections made = 1, result: GatewayFatal (closed with code 4014: demo)

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:

IDENTIFY: op=2 intents=37377 (expected 37377) properties=['browser', 'device', 'os'] heartbeats sent: True, last d = 6 REST calls the bot made: POST /channels/D1/messages reply_to=None allowed_mentions={'parse': []} content='Hi! (direct message reply)' POST /channels/C1/messages reply_to=m3 allowed_mentions={'parse': []} content='We open at nine on weekdays.' POST /channels/D1/messages reply_to=None allowed_mentions={'parse': []} content='Ping @everyone and <@123> if you like.' adapter events: ['ignored:not_addressed', 'empty_content']

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-โ€ฆ, with apps.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 its envelope_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-num and x-slack-retry-reason headers. 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 repeated event_id.
  • Proving a request is from Slack. Every request carries X-Slack-Request-Timestamp and X-Slack-Signature. The recipe in the docs: reject if the timestamp differs from your clock by more than five minutes; build v0:<timestamp>:<raw body>; compute HMAC-SHA256 with the signing secret; prefix the hex digest with v0=; 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_verification request containing a challenge, and the app must answer with the same challenge. (The Events API page I read only mentions that you may encounter url_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 unique event_id, team_id, api_app_id and the inner event. The adapter handles app_mention (the scope app_mentions:read is documented for this) and message events in a direct message (channel_type: "im"; the scope name im:history and this event shape are from my memory and not checked here).
  • Posting. chat.postMessage needs the chat:write scope. Docs: limit text to 4,000 characters for best results (over 40,000 is truncated); "generally 1 message per second to a specific channel"; thread_ts makes it a reply, and you should use the parent's timestamp; ratelimited errors come with a Retry-After header; not_in_channel means the bot has not been added to that channel.

The Code

import asyncio import hashlib import hmac import re import time from collections import deque import httpx from bot.channels.common import split_message from bot.core import Bot from bot.providers import ProviderError TEXT_LIMIT = 4000 # chat.postMessage docs: "limit the number of characters in the text field to 4,000" (over 40,000 is truncated) MAX_AGE = 300 # a signed request older than five minutes is refused (replay protection) def sign(signing_secret: str, timestamp: str, body: bytes) -> str: """The signature Slack puts in X-Slack-Signature: 'v0=' + hex HMAC-SHA256 of 'v0:<timestamp>:<raw body>'.""" basestring = b"v0:" + timestamp.encode() + b":" + body return "v0=" + hmac.new(signing_secret.encode(), basestring, hashlib.sha256).hexdigest() def verify_request(signing_secret: str, timestamp: str | None, body: bytes, signature: str | None, *, now: float | None = None) -> bool: """Check a request really came from Slack: fresh timestamp, signature computed over the RAW body, constant-time compare.""" if not timestamp or not signature: return False try: age = abs((now if now is not None else time.time()) - int(timestamp)) except ValueError: return False return age <= MAX_AGE and hmac.compare_digest(sign(signing_secret, timestamp, body), signature) class SlackError(Exception): def __init__(self, description: str, *, retry_after: float | None = None) -> None: super().__init__(description) self.retry_after = retry_after class SlackAPI: """The Slack Web API calls the bot needs. The bot token (xoxb-...) goes in the Authorization header.""" def __init__(self, token: str, *, base_url: str = "https://slack.com/api", timeout: float = 15.0, transport: httpx.AsyncBaseTransport | None = None) -> None: self._client = httpx.AsyncClient(base_url=base_url, timeout=timeout, transport=transport, headers={"Authorization": f"Bearer {token}"}) async def call(self, method: str, **params): try: response = await self._client.post(f"/{method}", json=params) except httpx.HTTPError as e: raise SlackError(f"{method}: {type(e).__name__}") from None if response.status_code == 429: raise SlackError("ratelimited", retry_after=float(response.headers.get("retry-after", "1"))) try: body = response.json() except ValueError: raise SlackError(f"{method}: HTTP {response.status_code} with a non-JSON body") from None if not body.get("ok"): raise SlackError(body.get("error", "unknown_error")) return body async def auth_test(self): return await self.call("auth.test") async def post_message(self, channel: str, text: str, thread_ts: str | None = None): params = {"channel": channel, "text": text} if thread_ts: params["thread_ts"] = thread_ts # reply in the thread, using the PARENT's ts return await self.call("chat.postMessage", **params) async def aclose(self) -> None: await self._client.aclose() class SlackAdapter: """Turns Slack Events API callbacks into Bot calls. Direct messages are one session per channel; in a channel the bot is only called when mentioned, and each thread is its own session.""" def __init__(self, bot: Bot, api: SlackAPI, *, bot_user_id: str | None = None, send_gap: float = 1.0, sleep=asyncio.sleep) -> None: self.bot, self.api, self.bot_user_id = bot, api, bot_user_id self.send_gap, self._sleep = send_gap, sleep self.events: list[str] = [] self._seen: deque[str] = deque(maxlen=1000) self._tasks: set[asyncio.Task] = set() async def start(self) -> None: self.bot_user_id = (await self.api.auth_test())["user_id"] def dispatch(self, payload: dict) -> None: task = asyncio.create_task(self.safe_handle(payload)) self._tasks.add(task) task.add_done_callback(self._tasks.discard) async def drain(self) -> None: while self._tasks: await asyncio.gather(*list(self._tasks)) async def safe_handle(self, payload: dict) -> None: try: await self.handle_callback(payload) except Exception as e: # noqa: BLE001 self.events.append(f"event_error:{type(e).__name__}") async def handle_callback(self, payload: dict) -> None: event_id = payload.get("event_id") if event_id in self._seen: # Slack retries when an acknowledgement is late; do not answer twice self.events.append("duplicate_event") return if event_id: self._seen.append(event_id) event = payload.get("event") or {} kind, channel_type = event.get("type"), event.get("channel_type") direct = kind == "message" and channel_type == "im" if not (kind == "app_mention" or direct): self.events.append("ignored:event_type") return if event.get("bot_id") or event.get("subtype") or event.get("user") in (None, self.bot_user_id): self.events.append("ignored:bot_or_subtype") # edits, joins, other bots and our own messages return text = re.sub(rf"<@{re.escape(str(self.bot_user_id))}>", "", event.get("text", "")).strip() if not text: self.events.append("ignored:empty") return channel, user = event["channel"], event["user"] thread = None if direct else (event.get("thread_ts") or event["ts"]) session = f"slack:{channel}" if direct else f"slack:{channel}:{thread}" try: answer = await self.bot.handle(session, text, user_id=f"slack:{user}") except ProviderError as e: self.events.append(f"provider_error:{type(e).__name__}") answer = self.bot.error_reply for number, piece in enumerate(split_message(answer, TEXT_LIMIT)): if number: await self._sleep(self.send_gap) # about one message a second per channel is allowed await self._send(channel, piece, thread) async def _send(self, channel: str, text: str, thread: str | None) -> None: for attempt in range(1, 4): try: await self.api.post_message(channel, text, thread) return except SlackError as e: if e.retry_after is None or attempt == 3: self.events.append(f"send_failed:{e}") return await self._sleep(e.retry_after) def add_slack_route(app, adapter: SlackAdapter, *, signing_secret: str, path: str = "/slack/events", clock=time.time) -> None: """The Events API endpoint. Verify, acknowledge at once (Slack wants a 2xx within three seconds), process in the background.""" from fastapi import Request from fastapi.responses import JSONResponse @app.post(path) async def events(request: Request): body = await request.body() # the RAW bytes: the signature covers exactly these if not verify_request(signing_secret, request.headers.get("x-slack-request-timestamp"), body, request.headers.get("x-slack-signature"), now=clock()): return JSONResponse({"detail": "bad signature"}, status_code=401) payload = await request.json() if payload.get("type") == "url_verification": # the handshake when you first save the Request URL return {"challenge": payload["challenge"]} if payload.get("type") == "event_callback": adapter.dispatch(payload) return {"ok": True}

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:

url_verification -> 200 {"challenge":"abc123"} unsigned event -> 401 {"detail":"bad signature"} signed but an hour old-> 401 {"detail":"bad signature"} signed event -> 200 {"ok":true} acknowledged after 0.19 s (the model takes 1.5 s) Slack's retry of it -> 200 {"ok":true} acknowledged after 0.24 s messages posted to Slack: /chat.postMessage channel=D1 thread_ts=None text='We open at nine.' /chat.postMessage channel=C1 thread_ts=17.2 text='We open at nine.' adapter events: ['duplicate_event'] | model calls: 2

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:

signature: v0=7416ec16f749537... independent calculation matches: True untouched request -> accepted wrong signing secret -> REFUSED body changed by one byte -> REFUSED body re-serialised (same data) -> REFUSED timestamp edited -> REFUSED request is 4 minutes old -> accepted request is 6 minutes old (replay) -> REFUSED signature header missing -> REFUSED

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

PlatformWhat I could establish
TelegramRead in full in chapter 9: privacy policy, deletion, encryption at rest, no dataset-building or AI training from user data without explicit, revocable consent.
DiscordNot 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.
SlackI 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:

sessions in the shared store: telegram:42 2 messages discord:D1 2 messages slack:D2 2 messages notes stored per user id: telegram:42 ['I like tea'] discord:42 [] slack:U42 []

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.

Mistakes while building this
  • A wrong number in a comment. I wrote # 50689 beside 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:ProgrammingError and 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 with check_same_thread=False (the real run_slack.py runs everything in one thread and does not need it). Two lessons: a catch-all keeps the bot up but hides faults, so the events list 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/docs addresses now redirect to docs.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 shards and session_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.py and run_slack.py) and refuse to start without their tokens, which I ran; nothing beyond that was run.

Hands-On Exercises

Exercise 1

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.

๐Ÿ“„ View solution
Exercise 2

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.

๐Ÿ“„ View solution
Exercise 3

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?

๐Ÿ“„ View solution

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 send allowed_mentions: {"parse": []}; 429 body has retry_after; REST token in the Authorization: Bot header
  • Slack Events API: acknowledge 2xx within 3 seconds, work in the background, dedupe event_id (retries after about 0, 1 and 5 minutes); verify X-Slack-Signature = v0= + HMAC-SHA256(secret, v0:ts:rawbody) with a 5-minute window; answer url_verification with 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, honour Retry-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)
Coming next
Build a Chatbot: From API Call to Deployed Bot 11 is about knowing what the bot is doing: structured logs that do not leak secrets or private chat, correlation ids that follow a message from the channel through the core to the provider, tests with the fake provider, "golden conversation" regression tests, simulated users, and the few metrics worth alerting on. The events lists you have seen in every chapter become real logs.