Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions src/study_discord_agent/discord_bot.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
18 changes: 18 additions & 0 deletions src/study_discord_agent/proactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
98 changes: 98 additions & 0 deletions tests/test_proactive.py
Original file line number Diff line number Diff line change
@@ -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"]
Loading