NOTE: Confirm you have a working environment first. For this ticket you create your own throwaway Discord server + bot to develop against (steps in the setup note below) and invite Jacc. The dev token + server ID get swapped for the real server later, once the bot works.
🧠 Context
The Discord bot is the planned (and only) deployment surface for cs-assistant, but src/apps/discord_bot.py is currently an empty stub. The core Q&A flow already works end-to-end via completion_service.ask(question) -> Answer (the dev CLI uses exactly this). This ticket stands up the simplest possible bot: one /ask slash command that runs that flow and replies. Polish — markdown formatting, long-message splitting, abstain styling — is intentionally a later ticket; keep this one minimal.
One Discord-specific constraint drives the design: a slash command must be acknowledged within ~3 seconds, but ask() takes 1–3 minutes locally. So the command must defer() immediately, then send the real answer as a followup (Discord allows up to ~15 minutes after a defer).
🛠 Implementation Plan
- Add the dependency:
uv add discord.py, and commit the updated uv.lock — CI runs uv sync --frozen, so a stale lock fails the build.
- Add the bot config the project way: add
discord_bot_token: str | None = None and discord_guild_id: int | None = None to Settings in src/config/__init__.py, plus matching lines in .env.example. Keep them optional (None defaults) so the CLI/tests/CI that don't set them still construct Settings fine. The bot should exit with a clear message if the token is unset at startup. Because both are read from .env, swapping to the real server later is just an .env change — no code edit.
- In
src/apps/discord_bot.py:
- Create a Discord client with a slash-command tree (
discord.Client + app_commands.CommandTree, or commands.Bot — either is fine for one command). Default intents are enough; the message_content intent is not needed for slash commands.
- Define an
/ask command taking a single string question argument.
- In the callback:
await interaction.response.defer() first (the 3-second ack), then answer = await completion_service.ask(question), then await interaction.followup.send(...) with answer.text and its sources.
- On startup (
on_ready), sync the command tree to your dev guild (using settings.discord_guild_id) — guild-scoped sync is instant, whereas global sync can take up to an hour to show up.
- Run via
client.run(settings.discord_bot_token).
- Add a
discord target to the Makefile mirroring the cli one, so others can start it the same way.
Notes
- Render
answer.text, then list answer.sources. Do not parse or strip sources in the bot — source handling belongs in the completion layer, not the renderer; the bot just displays what Answer gives it. (You may currently see sources appear both in the answer text and in the list — that's a known issue being fixed in the completion layer, not something to work around here.)
- Out of scope (deferred to the polish ticket): markdown formatting, splitting replies over Discord's 2000-char limit, and special formatting for the abstain answer. A long answer can exceed 2000 chars and error on send — that's a known limitation we're accepting for now, not something to solve in this ticket.
- Dev Discord setup (you can do this yourself): create your own throwaway server; in the Discord Developer Portal create an application + bot and copy its token; invite the bot to your server with the
bot + applications.commands scopes; put the token and your server's ID (enable Developer Mode, then right-click the server → Copy Server ID) into your .env. Invite Jacc to the server for review. These are throwaway dev values — the production server/token are swapped in later via .env.
✅ Acceptance Criteria
/ask appears as a slash command in your dev server and accepts a question argument.
- Invoking it defers immediately and then replies with the generated answer (no "application did not respond" timeout), including its sources.
discord.py is in pyproject.toml and the updated uv.lock is committed (CI's --frozen sync passes).
- The bot token is read from
Settings/.env, never hardcoded; the bot exits with a clear message if the token is missing.
make lint passes. (No automated tests required — this is verified manually in your dev server.)
NOTE: Confirm you have a working environment first. For this ticket you create your own throwaway Discord server + bot to develop against (steps in the setup note below) and invite Jacc. The dev token + server ID get swapped for the real server later, once the bot works.
🧠 Context
The Discord bot is the planned (and only) deployment surface for cs-assistant, but
src/apps/discord_bot.pyis currently an empty stub. The core Q&A flow already works end-to-end viacompletion_service.ask(question) -> Answer(the dev CLI uses exactly this). This ticket stands up the simplest possible bot: one/askslash command that runs that flow and replies. Polish — markdown formatting, long-message splitting, abstain styling — is intentionally a later ticket; keep this one minimal.One Discord-specific constraint drives the design: a slash command must be acknowledged within ~3 seconds, but
ask()takes 1–3 minutes locally. So the command mustdefer()immediately, then send the real answer as a followup (Discord allows up to ~15 minutes after a defer).🛠 Implementation Plan
uv add discord.py, and commit the updateduv.lock— CI runsuv sync --frozen, so a stale lock fails the build.discord_bot_token: str | None = Noneanddiscord_guild_id: int | None = NonetoSettingsinsrc/config/__init__.py, plus matching lines in.env.example. Keep them optional (Nonedefaults) so the CLI/tests/CI that don't set them still constructSettingsfine. The bot should exit with a clear message if the token is unset at startup. Because both are read from.env, swapping to the real server later is just an.envchange — no code edit.src/apps/discord_bot.py:discord.Client+app_commands.CommandTree, orcommands.Bot— either is fine for one command). Default intents are enough; themessage_contentintent is not needed for slash commands./askcommand taking a single stringquestionargument.await interaction.response.defer()first (the 3-second ack), thenanswer = await completion_service.ask(question), thenawait interaction.followup.send(...)withanswer.textand its sources.on_ready), sync the command tree to your dev guild (usingsettings.discord_guild_id) — guild-scoped sync is instant, whereas global sync can take up to an hour to show up.client.run(settings.discord_bot_token).discordtarget to theMakefilemirroring theclione, so others can start it the same way.Notes
answer.text, then listanswer.sources. Do not parse or strip sources in the bot — source handling belongs in the completion layer, not the renderer; the bot just displays whatAnswergives it. (You may currently see sources appear both in the answer text and in the list — that's a known issue being fixed in the completion layer, not something to work around here.)bot+applications.commandsscopes; put the token and your server's ID (enable Developer Mode, then right-click the server → Copy Server ID) into your.env. Invite Jacc to the server for review. These are throwaway dev values — the production server/token are swapped in later via.env.✅ Acceptance Criteria
/askappears as a slash command in your dev server and accepts aquestionargument.discord.pyis inpyproject.tomland the updateduv.lockis committed (CI's--frozensync passes).Settings/.env, never hardcoded; the bot exits with a clear message if the token is missing.make lintpasses. (No automated tests required — this is verified manually in your dev server.)