π§ Context
The bot answers questions today only through the /ask slash command β the user has to invoke a command and retype the question. This ticket lets people get an answer by @mentioning the bot, in two ways:
- Plain mention β
@bot what electives should I take? β the question is the text alongside the mention.
- Reply + mention β reply to someone else's message (e.g. a student's question) and write
@bot do you know? β the question is the message being replied to, optionally combined with the text in the mention.
(@bot throughout this ticket is shorthand for an actual @mention of this bot β whatever its username or nickname is in the server β not a literal string to match. See Notes for how a mention is detected.)
Both run the existing completion_service.ask(question) -> Answer flow β the same one /ask uses β and reply with the answer and its sources.
This is an on_message handler added to src/apps/discord_bot.py. The /ask command stays as-is (aside from sharing a small rendering helper, below). No completion_service change β the question is assembled in the bot and passed as a single string.
π Implementation Plan
-
Add an on_message handler to the bot (override async def on_message(self, message) on the client). The handler should:
- Ignore other bots and itself β
if message.author.bot: return (prevents reply loops, since the bot posts messages too).
- Trigger only on an explicit mention. Check that the bot's mention token is actually present in
message.content (<@{self.user.id}> or the nickname form <@!{self.user.id}>). Do not use self.user in message.mentions as the trigger: a reply to the bot's own message auto-pings the bot and would land it in message.mentions without the user typing anything, so the bot would respond to every "thanks" reply. The mention token in the content means the user actually typed @bot.
-
Assemble the question (extract this into a pure helper so it can be unit-tested β see step 6):
- Strip the mention from
message.content β remove both <@{id}> and <@!{id}> tokens and trim. The remainder is the "mention text."
- If the message is a reply (
message.reference is set): fetch the referenced message over REST with await message.channel.fetch_message(message.reference.message_id) and use its .content as the referenced text. Fetch it explicitly β do not rely on message.reference.resolved, whose content is empty without the privileged Message Content intent (REST fetch returns content regardless). Handle a missing/deleted reference (discord.NotFound) or a missing-permission error (discord.Forbidden, raised when the bot lacks Read Message History) as "no referenced text" β log the Forbidden case so a server admin knows to grant the permission.
- Combine: if it's a reply,
question = referenced_text + "\n\n" + mention_text (simple concatenation β the referenced text plus whatever the user added). If it's a plain mention, question = mention_text.
- If the resulting question is empty (e.g. a reply to an image-only message with no added text), do nothing (or send a short "I can only answer text questions" β your call). Don't call
ask() with an empty string.
-
Run the answer flow with a typing indicator. There is no 3-second ack requirement here β that was a slash-command (interaction) constraint and does not apply to on_message. Since ask() takes 1β3 minutes, wrap it so users see activity: async with message.channel.typing(): answer = await ask(question=question). Reply with message.reply(...) so the answer threads onto the user's message. Wrap ask() in try/except and send a short error reply on failure, mirroring how /ask already handles errors.
-
Share one rendering helper. Factor the "Answer β message string" logic (the answer text followed by the **Sources:** list) out of the /ask callback into a small module-level helper in discord_bot.py, and use it in both /ask and the new handler. This keeps the two renderers identical without duplicating the formatting. (This is an in-file tidy only β not the larger shared CLI/Discord formatting effort, which is separate.) As before, do not strip or parse sources in the bot β render what Answer provides.
-
Skips and edges: ignore the bot's own messages and other bots (step 1); skip empty/non-text questions (step 2); DMs are out of scope (the bot is guild-scoped) β keep the handler to guild channels.
-
Pure helpers + unit tests. Extract the mention-stripping and question-assembly into pure functions (input: raw content / bot id / referenced text β output: the question string) and unit-test them in a new test file under tests/ (e.g. tests/apps/test_discord_bot.py) with no Discord or network involved. Cover: a plain mention yields the stripped text; both <@id> and <@!id> forms are stripped; a reply concatenates referenced text + mention text; an empty result is detectable. The Discord wiring itself (on_message, the REST fetch, typing, reply) is verified manually on the dev server.
-
Document the bot permissions (these are granted in Discord when inviting the bot β they are not code). Update the Discord bot setup section of README.md to list the permissions the bot needs: Send Messages, View Channel, and Read Message History. Read Message History is the new requirement β reply-mode uses it to fetch the referenced message. Keep this in the setup section (a different part of the README from any deployment docs).
π Notes
@bot is a placeholder for an @mention of this bot, not a literal string. When a user types the mention, Discord encodes it in the raw message.content as the user-ID token <@{bot_id}> (or <@!{bot_id}> when the bot has a server nickname) β never as the text "@bot" or the bot's username. So detection and stripping work off the bot's user ID (self.user.id), which is also why they keep working if the bot is renamed or given a per-server nickname.
- No privileged intent and no Developer Portal change.
Intents.default() already delivers on_message (the guild_messages intent), and Discord populates content for messages that mention the bot even without the Message Content intent. The reply case reads the referenced message via REST, which isn't gated by intents. So this works with the current intents.
- The bot-ignore guard applies to the incoming message's author only (
message.author.bot) β never the referenced message's author. A human replying to the bot's own answer with @bot <follow-up> is a valid, supported case: the bot's prior answer becomes context for the follow-up. Do not also skip a message just because the message it replies to was posted by the bot β that would break follow-up questions. (Plain replies to the bot with no @bot token, e.g. "thanks", are already skipped by the token-in-content trigger, not by this guard.)
- Permissions are Discord server config, not code. They're granted via the bot's invite (OAuth2 URL) or its role/channel settings β the bot does not request them in its source. Reply-mode adds a Read Message History requirement (on top of View Channel and Send Messages) so the REST fetch can read the referenced message; without it the fetch raises
discord.Forbidden (handled in step 2). These go in the README setup section (step 7).
- Out of scope: auto-watching a forum/questions channel and answering new threads (that needs a reliable "can't answer β stay silent" signal, handled separately); markdown formatting and splitting replies over Discord's 2000-char limit and abstain styling (a separate formatting ticket β a long answer can still error on send here, which is accepted for now); and any change to how
ask() retrieves (the referenced text is folded into the one question string, nothing more).
/ask keeps working unchanged apart from now calling the shared rendering helper.
β
Acceptance Criteria
@bot <question> in a channel produces an answer (with sources) as a reply.
- Replying to a message with a mention (e.g.
@bot do you know?) answers using the replied-to message's content, combined with any text in the mention.
- The bot only responds when its mention token is in the message content β it does not respond to replies to its own messages that merely auto-ping it, and it ignores messages from bots/itself.
- A human replying to the bot's own answer with an explicit
@bot mention and a follow-up question is answered (the bot-ignore guard checks the incoming message's author, not the referenced message), with the prior answer folded in as context.
- The reply case fetches the referenced message via the API (works without the privileged Message Content intent); a deleted/missing reference (
NotFound) or a missing Read Message History permission (Forbidden) is handled gracefully, and an empty/non-text question is skipped rather than sent to ask().
- The README Discord bot setup section lists the required bot permissions: Send Messages, View Channel, and Read Message History.
- While answering, the bot shows a typing indicator; there is no "application did not respond" because
on_message has no ack deadline.
/ask and the mention handler render answers through one shared helper; sources are displayed, not parsed/stripped, in the bot.
- Mention-stripping and question-assembly are pure, unit-tested helpers (no Discord/network).
make test and make lint pass.
π§ Context
The bot answers questions today only through the
/askslash command β the user has to invoke a command and retype the question. This ticket lets people get an answer by @mentioning the bot, in two ways:@bot what electives should I take?β the question is the text alongside the mention.@bot do you know?β the question is the message being replied to, optionally combined with the text in the mention.(
@botthroughout this ticket is shorthand for an actual @mention of this bot β whatever its username or nickname is in the server β not a literal string to match. See Notes for how a mention is detected.)Both run the existing
completion_service.ask(question) -> Answerflow β the same one/askuses β and reply with the answer and its sources.This is an
on_messagehandler added tosrc/apps/discord_bot.py. The/askcommand stays as-is (aside from sharing a small rendering helper, below). Nocompletion_servicechange β the question is assembled in the bot and passed as a single string.π Implementation Plan
Add an
on_messagehandler to the bot (overrideasync def on_message(self, message)on the client). The handler should:if message.author.bot: return(prevents reply loops, since the bot posts messages too).message.content(<@{self.user.id}>or the nickname form<@!{self.user.id}>). Do not useself.user in message.mentionsas the trigger: a reply to the bot's own message auto-pings the bot and would land it inmessage.mentionswithout the user typing anything, so the bot would respond to every "thanks" reply. The mention token in the content means the user actually typed@bot.Assemble the question (extract this into a pure helper so it can be unit-tested β see step 6):
message.contentβ remove both<@{id}>and<@!{id}>tokens and trim. The remainder is the "mention text."message.referenceis set): fetch the referenced message over REST withawait message.channel.fetch_message(message.reference.message_id)and use its.contentas the referenced text. Fetch it explicitly β do not rely onmessage.reference.resolved, whose content is empty without the privileged Message Content intent (REST fetch returns content regardless). Handle a missing/deleted reference (discord.NotFound) or a missing-permission error (discord.Forbidden, raised when the bot lacks Read Message History) as "no referenced text" β log theForbiddencase so a server admin knows to grant the permission.question = referenced_text + "\n\n" + mention_text(simple concatenation β the referenced text plus whatever the user added). If it's a plain mention,question = mention_text.ask()with an empty string.Run the answer flow with a typing indicator. There is no 3-second ack requirement here β that was a slash-command (interaction) constraint and does not apply to
on_message. Sinceask()takes 1β3 minutes, wrap it so users see activity:async with message.channel.typing(): answer = await ask(question=question). Reply withmessage.reply(...)so the answer threads onto the user's message. Wrapask()in try/except and send a short error reply on failure, mirroring how/askalready handles errors.Share one rendering helper. Factor the "Answer β message string" logic (the answer text followed by the
**Sources:**list) out of the/askcallback into a small module-level helper indiscord_bot.py, and use it in both/askand the new handler. This keeps the two renderers identical without duplicating the formatting. (This is an in-file tidy only β not the larger shared CLI/Discord formatting effort, which is separate.) As before, do not strip or parse sources in the bot β render whatAnswerprovides.Skips and edges: ignore the bot's own messages and other bots (step 1); skip empty/non-text questions (step 2); DMs are out of scope (the bot is guild-scoped) β keep the handler to guild channels.
Pure helpers + unit tests. Extract the mention-stripping and question-assembly into pure functions (input: raw content / bot id / referenced text β output: the question string) and unit-test them in a new test file under
tests/(e.g.tests/apps/test_discord_bot.py) with no Discord or network involved. Cover: a plain mention yields the stripped text; both<@id>and<@!id>forms are stripped; a reply concatenates referenced text + mention text; an empty result is detectable. The Discord wiring itself (on_message, the REST fetch, typing, reply) is verified manually on the dev server.Document the bot permissions (these are granted in Discord when inviting the bot β they are not code). Update the Discord bot setup section of
README.mdto list the permissions the bot needs: Send Messages, View Channel, and Read Message History. Read Message History is the new requirement β reply-mode uses it to fetch the referenced message. Keep this in the setup section (a different part of the README from any deployment docs).π Notes
@botis a placeholder for an @mention of this bot, not a literal string. When a user types the mention, Discord encodes it in the rawmessage.contentas the user-ID token<@{bot_id}>(or<@!{bot_id}>when the bot has a server nickname) β never as the text "@bot" or the bot's username. So detection and stripping work off the bot's user ID (self.user.id), which is also why they keep working if the bot is renamed or given a per-server nickname.Intents.default()already deliverson_message(theguild_messagesintent), and Discord populates content for messages that mention the bot even without the Message Content intent. The reply case reads the referenced message via REST, which isn't gated by intents. So this works with the current intents.message.author.bot) β never the referenced message's author. A human replying to the bot's own answer with@bot <follow-up>is a valid, supported case: the bot's prior answer becomes context for the follow-up. Do not also skip a message just because the message it replies to was posted by the bot β that would break follow-up questions. (Plain replies to the bot with no@bottoken, e.g. "thanks", are already skipped by the token-in-content trigger, not by this guard.)discord.Forbidden(handled in step 2). These go in the README setup section (step 7).ask()retrieves (the referenced text is folded into the one question string, nothing more)./askkeeps working unchanged apart from now calling the shared rendering helper.β Acceptance Criteria
@bot <question>in a channel produces an answer (with sources) as a reply.@bot do you know?) answers using the replied-to message's content, combined with any text in the mention.@botmention and a follow-up question is answered (the bot-ignore guard checks the incoming message's author, not the referenced message), with the prior answer folded in as context.NotFound) or a missing Read Message History permission (Forbidden) is handled gracefully, and an empty/non-text question is skipped rather than sent toask().on_messagehas no ack deadline./askand the mention handler render answers through one shared helper; sources are displayed, not parsed/stripped, in the bot.make testandmake lintpass.