Chat bot layer (Discord / Slack / LINE / Gmail)¶
The template can layer a long-running chat bot onto a cli or web_api
project. It is the second implementation of the
long-running-executables layer model
(the first is the MCP server): an opt-in question that generates a
bot module with its own entry point onto an otherwise ordinary project —
never a new project type.
Answering Yes to include_bot (asked for the cli / web_api bases)
generates the discord scaffold, the recommended platform. Answer No
to use_recommended_bot to pick from bot_platform, whose other choices are
slack, line and gmail:
bot_platform |
Module | Runtime dependency | Credentials |
|---|---|---|---|
discord (recommended) |
bot_discord.py |
discord.py>=2,<3 |
DISCORD_BOT_TOKEN |
slack |
bot_slack.py |
slack-bolt>=1.21,<2 |
SLACK_BOT_TOKEN + SLACK_APP_TOKEN |
line |
bot_line.py |
line-bot-sdk>=3,<4 + fastapi / uvicorn[standard] |
LINE_CHANNEL_SECRET + LINE_CHANNEL_ACCESS_TOKEN |
gmail |
bot_gmail.py |
google-api-python-client + google-auth + google-auth-oauthlib |
GMAIL_CREDENTIALS_JSON + GMAIL_TOKEN_JSON (paths to the two OAuth JSON files) |
Whichever platform is selected, the layer ships:
<pkg>/bot_<platform>.py(orapp/bot_<platform>.pyon aweb_apibase) — the platform's client configured inbuild_*(), a listener that answerspong(the liveness example every platform starts with), and amain()split so tests drive the listener without connecting. Adds the platform's runtime dependency.- a
bot-<platform>-<name>console script — the dedicated entry point for the long-running process (python -m <pkg>.bot_<platform>works too). The CLI's__main__.py, the DockerENTRYPOINTand their tests are untouched. tests/test_bot_<platform>.py— in-process tests: the listener is driven with a fake client (no connection, no token, no network), and the startup refusal is asserted.- the token variable(s) in
.env.example, plus abot-servetask (docker and include_bot) that builds the image and runs the bot with the tokens passed through from the host environment. The task follows the selected platform: one entry point, one token set.
Three of the four generated platforms reach their service over an outbound connection, so none needs a public endpoint: Discord over the Gateway, Slack over Socket Mode, Gmail by polling the INBOX. That is the property the MCP stdio transport has too, and the reason those three work on a laptop and behind NAT. LINE is the exception — the Messaging API can only push, so its bot serves a webhook.
Discord¶
Getting a Discord token¶
- Create an application at https://discord.com/developers/applications (New Application).
- Open the Bot tab and copy the token (Reset Token to see it once).
- Copy
.env.exampleto.envand setDISCORD_BOT_TOKEN=<the token>..envis git-ignored and loaded by direnv — never commit the token. - Invite the bot to a server: OAuth2 → URL Generator, scopes
bot+applications.commands, then open the generated URL.
Run it¶
uv run bot-discord-<name>
Slash commands are registered on login by setup_hook: globally by
default (Discord caches global commands for up to an hour), or instantly
inside one guild for development:
uv run bot-discord-<name> --sync-guild <guild id>
Then type /ping in any channel the bot can see — it replies pong
(ephemerally). Logging goes through the generated logging_setup, so
LOG_FORMAT=json gives the Gateway process the same structured logs as the
CLI. With Docker: task bot-serve / just bot-serve (needs
DISCORD_BOT_TOKEN in the environment).
Privileged intents and scaling¶
The scaffold subscribes to discord.Intents.default() only: no privileged
intent is requested, so a default bot token works with no Developer-portal
toggles and no verification friction. Turn on members /
message_content in both the portal and _intents() only when you actually
read member lists or message bodies.
Slack¶
Getting the two Slack tokens¶
Slack splits the credential in two: a bot token authorizes the Web API
calls the listeners make (say() posts a message), and an app-level token
opens the Socket Mode connection. The scaffold refuses to start without either
and names the missing one.
- Create the app at https://api.slack.com/apps → Create New App → From scratch (pick the workspace).
- Socket Mode → toggle it on. Slack offers to generate an app-level
token for you; it needs the
connections:writescope. Thatxapp-…value isSLACK_APP_TOKEN. - OAuth & Permissions → Scopes → Bot Token Scopes → add
app_mentions:read(receive@mentions) andchat:write(post the reply). Then Install to Workspace and copy the Bot User OAuth Tokenxoxb-…asSLACK_BOT_TOKEN. - Event Subscriptions → toggle it on → Subscribe to bot events →
add
app_mention. Socket Mode delivers these over the socket, so no Request URL is involved (and no request signing secret to configure). - Copy
.env.exampleto.envand set both variables..envis git-ignored and loaded by direnv — never commit either token. Both are secrets: a leakedxoxb-posts as your bot, a leakedxapp-opens your socket. - Invite the bot to a channel you will use:
/invite @your-bot. A bot only receives mentions in channels it is a member of.
Run it¶
uv run bot-slack-<name>
Socket Mode connects out to Slack, so nothing listens on a port and there is
nothing to expose (the generated Dockerfile therefore adds no EXPOSE for
it). Mention the bot — @your-bot ping — in a channel it is in, and it
replies pong. Logging goes through the generated logging_setup, so
LOG_FORMAT=json gives the socket process the same structured logs as the
CLI; Bolt keeps its own stdlib loggers, the way the Discord module leaves
discord.py's alone. With Docker: task bot-serve / just bot-serve (needs
both SLACK_BOT_TOKEN and SLACK_APP_TOKEN in the environment).
register_listeners() is split from build_app() and main():
tests/test_bot_slack.py registers the listeners on a recording stand-in for
slack_bolt.App and calls the mention listener with a fake say — no token,
no WebClient, no socket. build_app(token=...) passes
token_verification_enabled=False, because App would otherwise call
auth.test while the app is being built.
LINE¶
Getting the two LINE credentials¶
LINE splits the credential the same way Slack does, but along a different axis: a channel secret verifies the webhook, and a channel access token authorizes the Messaging API calls the bot makes when it replies. The scaffold refuses to start without either and names the missing one.
- Create a provider and a Messaging API channel at https://developers.line.biz/console/.
- Messaging API tab → Channel access token (long-lived) → issue one.
That value is
LINE_CHANNEL_ACCESS_TOKEN. - Basic settings tab → Channel secret. That value is
LINE_CHANNEL_SECRET; it is what LINE signs every webhook body with. - Copy
.env.exampleto.envand set both variables..envis git-ignored and loaded by direnv — never commit either value. A leaked access token sends messages as your channel; a leaked channel secret lets anyone forge a webhook. - Add the bot as a friend (the channel's QR code is on the Messaging API tab). A LINE user can only message a bot they added.
Run it¶
uv run bot-line-<name>
Unlike the other two platforms this one listens: the bot serves
POST /callback on BOT_PORT (default 8000, all interfaces), so it needs an
HTTPS URL LINE can reach. Deploy it behind a reverse proxy, or tunnel to it
while developing:
cloudflared tunnel --url http://localhost:8000 # or: ngrok http 8000
Then, on the Messaging API tab:
- Webhook settings → Webhook URL:
https://<that host>/callback→ Verify (LINE sends a signed test request; the scaffold answers it, so the check passes). - Use webhook → on, and turn Auto-reply messages off so the channel's canned replies do not answer first.
- Send the bot
pingin the chat — it repliespong.
Logging goes through the generated logging_setup, so LOG_FORMAT=json
gives the webhook process the same structured logs as the CLI. With Docker:
task bot-serve / just bot-serve (needs both credentials in the
environment; this is the one bot container that publishes a port — 8000).
Why the signature check is what makes a public URL safe¶
The webhook URL is public by design: LINE has to reach it, so anyone who
learns it can POST to /callback. What separates LINE from a forger is the
channel secret, which never travels over the wire. LINE sends the base64
HMAC-SHA256 of the raw request body in the X-Line-Signature header, and the
scaffold's WebhookParser recomputes it over the exact bytes received and
rejects the request — 400, no reply, no event parsed — when it does not
match. An unsigned body and a body signed with the wrong secret are both that
same 400.
tests/test_bot_line.py asserts exactly that, plus the happy path: a
correctly signed ping gets the pong reply, the two forged bodies get
rejected without a single reply call, and the startup refusal names the
missing credential. build_app() is split from main() and takes the reply
call as a parameter, so the whole route — signature check included — runs
in-process over httpx's ASGITransport with a recording reply: no token, no
client, no network. (The transport is httpx's rather than starlette's
TestClient for the reason tests/test_app.py documents: the generated
project sets filterwarnings = error, and starlette 1.x deprecates httpx
inside TestClient.)
Gmail¶
Getting the two Gmail credential files¶
Gmail splits the credential like Slack and LINE do, but into files instead of environment strings: the OAuth client identifies the application, and the granted token is the inbox itself. The scaffold refuses to start without either path in the environment and names the missing one.
- In Google Cloud Console (https://console.cloud.google.com/), create a project and enable the Gmail API (APIs & Services → Library). While the app is unpublished, add your own account as a test user on the OAuth consent screen.
- APIs & Services → Credentials → Create credentials → OAuth client ID →
Desktop app, download the JSON, and keep it as
credentials.json(the repo root is the convention; both names are git-ignored). That path isGMAIL_CREDENTIALS_JSON. - Copy
.env.exampleto.envand setGMAIL_TOKEN_JSON=token.json— the file does not exist yet; the first run writes it. Never commit either file: the token IS the inbox. The generated.gitignorecovers both the way it covers CTFflag*files. - Scope: the scaffold asks for
gmail.modifyonly — read, send and label edit, everything the poll needs, nothing more.
Run it¶
uv run bot-gmail-<name>
The first run opens a browser once for consent and writes token.json;
every later run refreshes the stored grant, so nothing sits between uv
run and the mailbox. The bot then polls every GMAIL_POLL_SECONDS
(default 60): each unread INBOX message whose subject contains /ping
gets a pong reply on its own thread and is marked read — which is what
makes the poll answer each ping exactly once. Mail that never says /ping
is left unread: the bot never hides a human's mail. Logging goes through
the generated logging_setup, so LOG_FORMAT=json gives the poll loop
the same structured logs as the CLI. With Docker: task bot-serve /
just bot-serve (the credential files are mounted read-only into the
container; consent once on the host first).
Why polling and not Pub/Sub push? Push would need a public HTTPS endpoint to receive at — infrastructure to host and verify. Polling is an outbound call, the same self-contained property the other dial-out platforms have, at the cost of the interval's latency instead of push immediacy.
poll_once() takes the API client as a parameter, the seam the tests
fake: tests/test_bot_gmail.py hands it a recording stand-in for the
discovery client and asserts the exact call set — list, get, send,
modify — decoding the RFC 2822 reply bytes right in the test.
build_service() is split from main() like every platform's
build_*(); the OAuth dance is the only part that cannot run offline,
and it is the only part the tests skip.
Non-goals¶
The generated bots are working starting points, not frameworks: no moderation tooling, no databases/persistence, no scheduled tasks, no voice, no i18n, no command catalogue. Platform-specific operational concerns (rate-limit handling and reconnection backoff are discord.py's / Bolt's / line-bot-sdk's own, on by default; the Gmail poller simply retries on the next tick) are not re-implemented. Add the web_api layer (or any other) on top by answering the same gates.