Skip to content

Add @mention handler for Discord botΒ #14

@AJaccP

Description

@AJaccP

🧠 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:

  1. Plain mention β€” @bot what electives should I take? β†’ the question is the text alongside the mention.
  2. 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

  1. 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.
  2. 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.
  3. 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.

  4. 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.

  5. 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.

  6. 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.

  7. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    Status
    Ready

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions