From 521f07c9b49b160ee3d31c72cac663be0d7b3c38 Mon Sep 17 00:00:00 2001 From: StudyOS Org Date: Fri, 3 Jul 2026 06:17:53 +0000 Subject: [PATCH] fix: prevent proactive replies during mentions --- src/study_discord_agent/discord_bot.py | 4 ++ src/study_discord_agent/proactive.py | 18 +++++ tests/test_proactive.py | 98 ++++++++++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 tests/test_proactive.py diff --git a/src/study_discord_agent/discord_bot.py b/src/study_discord_agent/discord_bot.py index b5b5e64..cf53071 100644 --- a/src/study_discord_agent/discord_bot.py +++ b/src/study_discord_agent/discord_bot.py @@ -213,6 +213,10 @@ async def _forget_mention_task( if self._active_mention_tasks.get(channel_id) is task: self._active_mention_tasks.pop(channel_id, None) + def has_active_mention_task(self, channel_id: int) -> bool: + task = self._active_mention_tasks.get(channel_id) + return task is not None and not task.done() + async def _handle_agent_mention( self, message: discord.Message, diff --git a/src/study_discord_agent/proactive.py b/src/study_discord_agent/proactive.py index 1d2843e..fc35033 100644 --- a/src/study_discord_agent/proactive.py +++ b/src/study_discord_agent/proactive.py @@ -104,6 +104,13 @@ async def check_channel(self, channel: discord.abc.Messageable) -> None: if self._last_processed_human_message_ids.get(channel_id) == latest_human_message_id: logger.info("proactive no-new-human-message channel_id=%s", channel_id) return + if self._has_active_mention_task(channel_id): + logger.info("proactive skipped active mention channel_id=%s", channel_id) + return + if self._mentions_client_user(latest_human_message): + self._last_processed_human_message_ids[channel_id] = latest_human_message_id + logger.info("proactive skipped bot mention channel_id=%s", channel_id) + return if self._is_in_post_cooldown(channel_id): logger.info("proactive cooldown channel_id=%s", channel_id) return @@ -150,6 +157,17 @@ def _is_in_post_cooldown(self, channel_id: int) -> bool: elapsed = (datetime.now(UTC) - last_sent_at).total_seconds() return elapsed < self.settings.discord_proactive_min_post_interval_seconds + def _has_active_mention_task(self, channel_id: int) -> bool: + checker = getattr(self.client, "has_active_mention_task", None) + return bool(callable(checker) and checker(channel_id)) + + def _mentions_client_user(self, message: Any) -> bool: + user = getattr(self.client, "user", None) + if user is None: + return False + mentions = getattr(message, "mentions", ()) + return any(mention == user for mention in mentions) + def latest_recent_human_message(self, messages: list[Any]) -> Any | None: human_messages = [ message diff --git a/tests/test_proactive.py b/tests/test_proactive.py new file mode 100644 index 0000000..51d5ad2 --- /dev/null +++ b/tests/test_proactive.py @@ -0,0 +1,98 @@ +from datetime import UTC, datetime +from typing import Any + +import pytest + +from study_discord_agent.proactive import ProactiveMonitor + + +class FakeAgent: + def __init__(self) -> None: + self.calls: list[dict[str, Any]] = [] + + async def ask(self, prompt: str, user: str, channel_id: int | None) -> object: + self.calls.append({"prompt": prompt, "user": user, "channel_id": channel_id}) + return type("Reply", (), {"message": "done"})() + + +class FakeClient: + def __init__(self) -> None: + self.user = object() + self.active_channels: set[int] = set() + + def has_active_mention_task(self, channel_id: int) -> bool: + return channel_id in self.active_channels + + +class FakeSettings: + discord_proactive_recent_activity_seconds = 1800 + discord_proactive_dry_run = False + + +class FakeAuthor: + bot = False + + def __str__(self) -> str: + return "student" + + +class FakeMessage: + def __init__(self, message_id: int, content: str, mentions: list[object] | None = None) -> None: + self.id = message_id + self.clean_content = content + self.author = FakeAuthor() + self.created_at = datetime.now(UTC) + self.mentions = mentions or [] + + +class FakeChannel: + def __init__(self, channel_id: int, messages: list[FakeMessage]) -> None: + self.id = channel_id + self.messages = messages + self.sent: list[str] = [] + + def history(self, limit: int) -> object: + del limit + return self._iter_messages() + + async def _iter_messages(self) -> object: + for message in self.messages: + yield message + + async def send(self, text: str) -> object: + self.sent.append(text) + return type("SentMessage", (), {"id": 999})() + + +@pytest.mark.asyncio +async def test_proactive_skips_latest_message_that_mentions_bot() -> None: + client = FakeClient() + agent = FakeAgent() + monitor = ProactiveMonitor(client, FakeSettings(), agent) # type: ignore[arg-type] + channel = FakeChannel(123, [FakeMessage(1, "@StudyOS Bot please help", [client.user])]) + + await monitor.check_channel(channel) # type: ignore[arg-type] + await monitor.check_channel(channel) # type: ignore[arg-type] + + assert agent.calls == [] + assert channel.sent == [] + + +@pytest.mark.asyncio +async def test_proactive_skips_channel_with_active_mention_task() -> None: + client = FakeClient() + client.active_channels.add(123) + agent = FakeAgent() + monitor = ProactiveMonitor(client, FakeSettings(), agent) # type: ignore[arg-type] + channel = FakeChannel(123, [FakeMessage(1, "follow-up without direct mention")]) + + await monitor.check_channel(channel) # type: ignore[arg-type] + + assert agent.calls == [] + assert channel.sent == [] + + client.active_channels.clear() + await monitor.check_channel(channel) # type: ignore[arg-type] + + assert len(agent.calls) == 1 + assert channel.sent == ["done"]