diff --git a/README.md b/README.md index 0114494..e8d6697 100644 --- a/README.md +++ b/README.md @@ -162,11 +162,13 @@ You'll need your own throwaway Discord server and bot to develop against: 1. In the [Discord Developer Portal](https://discord.com/developers/applications), create an application, then under **Bot** click **Reset Token** and copy the token. No privileged intents are required — `/ask` uses default intents. -2. Under **OAuth2 → URL Generator**, select the `bot` and `applications.commands` +2. Scroll down and check the permissions the bot needs: **Send Messages**, + **View Channel** and **Read Message History**. +3. Under **OAuth2 → URL Generator**, select the `bot` and `applications.commands` scopes, open the generated URL, and invite the bot to a server you own. -3. Enable **Settings → Advanced → Developer Mode**, then right-click your server +4. Enable **Settings → Advanced → Developer Mode**, then right-click your server icon → **Copy Server ID**. -4. Add both values to your `.env`: +5. Add both values to your `.env`: ``` DISCORD_BOT_TOKEN=your_token_here diff --git a/src/apps/discord_bot.py b/src/apps/discord_bot.py index a412ea3..480dafc 100644 --- a/src/apps/discord_bot.py +++ b/src/apps/discord_bot.py @@ -22,6 +22,38 @@ async def on_ready(self) -> None: await self.tree.sync(guild=guild) log.info("discord_ready", self_user=self.user, guild=settings.discord_guild_id) + async def on_message(self, message) -> None: + # runs only in servers + if message.guild is None: + return + + # ignores other bots and itself + if message.author.bot: + return + + bot_standard_mention = f"<@{self.user.id}>" + bot_nickname_mention = f"<@!{self.user.id}>" + + # triggering on explicit mention + if bot_standard_mention in message.content or bot_nickname_mention in message.content: + question = await question_assembler(message) + + # if question is just empty/non-text + if question == "": + return + + async with message.channel.typing(): + answer = await ask(question=question) + + content = await render_answer_content(answer) + + try: + await message.reply(content) + except Exception as e: + await message.reply("❌ I couldn't process this request, please try again later.") + log.error("discord_ask_failed", error=str(e)) + return + client = CsAssistantBot() @@ -41,12 +73,62 @@ async def ask_command(interaction: discord.Interaction, question: str) -> None: log.error("discord_ask_failed", interaction_id=interaction.id, error=str(e)) return + content = await render_answer_content(answer) + await interaction.followup.send(content) + log.info("discord_ask_completed", interaction_id=interaction.id) + + +async def question_assembler(message) -> str: + # if message is reply to a question + referenced_text = "" + if message.reference and message.reference.message_id: + try: + referenced_message = await message.channel.fetch_message(message.reference.message_id) + referenced_text = referenced_message.content + except discord.NotFound: + log.info( + "No referenced text. Not Found.", + channel_id=message.channel.id, + channel_name=message.channel.name, + guild_id=message.guild.id, + target_message_id=message.reference.message_id, + ) + except discord.Forbidden: + log.warning( + "No referenced text. Forbidden.", + channel_id=message.channel.id, + channel_name=message.channel.name, + guild_id=message.guild.id, + target_message_id=message.reference.message_id, + ) + + return question_assembler_helper( + content=message.content, + bot_id=message.guild.me.id, + referenced_text=referenced_text, + ) + + +def question_assembler_helper(*, content: str, bot_id: int, referenced_text: str = "") -> str: + mention_text = strip_bot_mention(content, bot_id) + + if referenced_text: + return referenced_text + "\n\n" + mention_text + return mention_text + + +def strip_bot_mention(content: str, bot_id: int) -> str: + mention_text = content + for x in {f"<@{bot_id}>", f"<@!{bot_id}>"}: + mention_text = mention_text.replace(x, "") + return mention_text.strip() + + +async def render_answer_content(answer) -> str: content = answer.text if answer.sources: content += "\n\n**Sources:**\n" + "\n".join(f"• {source.url}" for source in answer.sources) - - await interaction.followup.send(content) - log.info("discord_ask_completed", interaction_id=interaction.id) + return content if __name__ == "__main__": diff --git a/tests/apps/test_discord_bot.py b/tests/apps/test_discord_bot.py new file mode 100644 index 0000000..9258e2f --- /dev/null +++ b/tests/apps/test_discord_bot.py @@ -0,0 +1,27 @@ +from src.apps.discord_bot import question_assembler_helper, strip_bot_mention + +BOT_ID = 123456789 + + +def test_plain_mention(): + message = f"<@{BOT_ID}> test question" + assert (strip_bot_mention(message, BOT_ID)) == "test question" + + +def test_both_mentions(): + message = f"<@{BOT_ID}> <@!{BOT_ID}> test question" + assert (strip_bot_mention(message, BOT_ID)) == "test question" + + +def test_concatenate_reference_and_original(): + message = f"<@{BOT_ID}> test question" + referenced_text = "Original text" + assert ( + question_assembler_helper(content=message, bot_id=BOT_ID, referenced_text=referenced_text) + == "Original text\n\ntest question" + ) + + +def test_empty_response(): + message = f"<@{BOT_ID}>" + assert (question_assembler_helper(content=message, bot_id=BOT_ID)) == ""