diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index 74aaa75a453..0d210cf482c 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -770,18 +770,34 @@ class DiscordAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None ) -> SendResult: - """Send a message to a Discord channel.""" + """Send a message to a Discord channel or thread. + + When metadata contains a thread_id, the message is sent to that + thread instead of the parent channel identified by chat_id. + """ if not self._client: return SendResult(success=False, error="Not connected") try: - # Get the channel - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) + # Determine target channel: thread_id in metadata takes precedence. + thread_id = None + if metadata and metadata.get("thread_id"): + thread_id = metadata["thread_id"] - if not channel: - return SendResult(success=False, error=f"Channel {chat_id} not found") + if thread_id: + # Fetch the thread directly — threads are addressed by their own ID. + channel = self._client.get_channel(int(thread_id)) + if not channel: + channel = await self._client.fetch_channel(int(thread_id)) + if not channel: + return SendResult(success=False, error=f"Thread {thread_id} not found") + else: + # Get the parent channel + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") # Format and split message if needed formatted = self.format_message(content) diff --git a/tests/cron/test_scheduler.py b/tests/cron/test_scheduler.py index c07663a37de..08b57cfa897 100644 --- a/tests/cron/test_scheduler.py +++ b/tests/cron/test_scheduler.py @@ -173,6 +173,40 @@ class TestResolveDeliveryTarget: "thread_id": None, } + def test_explicit_discord_topic_target_with_thread_id(self): + """deliver: 'discord:chat_id:thread_id' parses correctly.""" + job = { + "deliver": "discord:-1001234567890:17585", + } + assert _resolve_delivery_target(job) == { + "platform": "discord", + "chat_id": "-1001234567890", + "thread_id": "17585", + } + + def test_explicit_discord_chat_id_without_thread_id(self): + """deliver: 'discord:chat_id' sets thread_id to None.""" + job = { + "deliver": "discord:9876543210", + } + assert _resolve_delivery_target(job) == { + "platform": "discord", + "chat_id": "9876543210", + "thread_id": None, + } + + def test_explicit_discord_channel_without_thread(self): + """deliver: 'discord:1001234567890' resolves via explicit platform:chat_id path.""" + job = { + "deliver": "discord:1001234567890", + } + result = _resolve_delivery_target(job) + assert result == { + "platform": "discord", + "chat_id": "1001234567890", + "thread_id": None, + } + class TestDeliverResultWrapping: """Verify that cron deliveries are wrapped with header/footer and no longer mirrored.""" diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index 94370e4d5b8..d6f07e2e684 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -9,7 +9,13 @@ from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock, patch from gateway.config import Platform -from tools.send_message_tool import _send_telegram, _send_to_platform, send_message_tool +from tools.send_message_tool import ( + _parse_target_ref, + _send_discord, + _send_telegram, + _send_to_platform, + send_message_tool, +) def _run_async_immediately(coro): @@ -700,3 +706,151 @@ class TestSendTelegramHtmlDetection: assert bot.send_message.await_count == 2 second_call = bot.send_message.await_args_list[1].kwargs assert second_call["parse_mode"] is None + + +# --------------------------------------------------------------------------- +# Tests for Discord thread_id support +# --------------------------------------------------------------------------- + + +class TestParseTargetRefDiscord: + """_parse_target_ref correctly extracts chat_id and thread_id for Discord.""" + + def test_discord_chat_id_with_thread_id(self): + """discord:chat_id:thread_id returns both values.""" + chat_id, thread_id, is_explicit = _parse_target_ref("discord", "-1001234567890:17585") + assert chat_id == "-1001234567890" + assert thread_id == "17585" + assert is_explicit is True + + def test_discord_chat_id_without_thread_id(self): + """discord:chat_id returns None for thread_id.""" + chat_id, thread_id, is_explicit = _parse_target_ref("discord", "9876543210") + assert chat_id == "9876543210" + assert thread_id is None + assert is_explicit is True + + def test_discord_large_snowflake_without_thread(self): + """Large Discord snowflake IDs work without thread.""" + chat_id, thread_id, is_explicit = _parse_target_ref("discord", "1003724596514") + assert chat_id == "1003724596514" + assert thread_id is None + assert is_explicit is True + + def test_discord_channel_with_thread(self): + """Full Discord format: channel:thread.""" + chat_id, thread_id, is_explicit = _parse_target_ref("discord", "1003724596514:99999") + assert chat_id == "1003724596514" + assert thread_id == "99999" + assert is_explicit is True + + def test_discord_whitespace_is_stripped(self): + """Whitespace around Discord targets is stripped.""" + chat_id, thread_id, is_explicit = _parse_target_ref("discord", " 123456:789 ") + assert chat_id == "123456" + assert thread_id == "789" + assert is_explicit is True + + +class TestSendDiscordThreadId: + """_send_discord uses thread_id when provided.""" + + @staticmethod + def _build_mock(response_status, response_data=None, response_text="error body"): + """Build a properly-structured aiohttp mock chain. + + session.post() returns a context manager yielding mock_resp. + """ + mock_resp = MagicMock() + mock_resp.status = response_status + mock_resp.json = AsyncMock(return_value=response_data or {"id": "msg123"}) + mock_resp.text = AsyncMock(return_value=response_text) + + # mock_resp as async context manager (for "async with session.post(...) as resp") + mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) + mock_resp.__aexit__ = AsyncMock(return_value=None) + + mock_session = MagicMock() + mock_session.__aenter__ = AsyncMock(return_value=mock_session) + mock_session.__aexit__ = AsyncMock(return_value=None) + mock_session.post = MagicMock(return_value=mock_resp) + + return mock_session, mock_resp + + def _run(self, token, chat_id, message, thread_id=None): + return asyncio.run(_send_discord(token, chat_id, message, thread_id=thread_id)) + + def test_without_thread_id_uses_chat_id_endpoint(self): + """When no thread_id, sends to /channels/{chat_id}/messages.""" + mock_session, _ = self._build_mock(200) + with patch("aiohttp.ClientSession", return_value=mock_session): + self._run("tok", "111222333", "hello world") + call_url = mock_session.post.call_args.args[0] + assert call_url == "https://discord.com/api/v10/channels/111222333/messages" + + def test_with_thread_id_uses_thread_endpoint(self): + """When thread_id is provided, sends to /channels/{thread_id}/messages.""" + mock_session, _ = self._build_mock(200) + with patch("aiohttp.ClientSession", return_value=mock_session): + self._run("tok", "999888777", "hello from thread", thread_id="555444333") + call_url = mock_session.post.call_args.args[0] + assert call_url == "https://discord.com/api/v10/channels/555444333/messages" + + def test_success_returns_message_id(self): + """Successful send returns the Discord message ID.""" + mock_session, _ = self._build_mock(200, response_data={"id": "9876543210"}) + with patch("aiohttp.ClientSession", return_value=mock_session): + result = self._run("tok", "111", "hi", thread_id="999") + assert result["success"] is True + assert result["message_id"] == "9876543210" + assert result["chat_id"] == "111" + + def test_error_status_returns_error_dict(self): + """Non-200/201 responses return an error dict.""" + mock_session, _ = self._build_mock(403, response_data={"message": "Forbidden"}) + with patch("aiohttp.ClientSession", return_value=mock_session): + result = self._run("tok", "111", "hi") + assert "error" in result + assert "403" in result["error"] + + +class TestSendToPlatformDiscordThread: + """_send_to_platform passes thread_id through to _send_discord.""" + + def test_discord_thread_id_passed_to_send_discord(self): + """Discord platform with thread_id passes it to _send_discord.""" + send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) + + with patch("tools.send_message_tool._send_discord", send_mock): + result = asyncio.run( + _send_to_platform( + Platform.DISCORD, + SimpleNamespace(enabled=True, token="tok", extra={}), + "-1001234567890", + "hello thread", + thread_id="17585", + ) + ) + + assert result["success"] is True + send_mock.assert_awaited_once() + _, call_kwargs = send_mock.await_args + assert call_kwargs["thread_id"] == "17585" + + def test_discord_no_thread_id_when_not_provided(self): + """Discord platform without thread_id passes None.""" + send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) + + with patch("tools.send_message_tool._send_discord", send_mock): + result = asyncio.run( + _send_to_platform( + Platform.DISCORD, + SimpleNamespace(enabled=True, token="tok", extra={}), + "9876543210", + "hello channel", + ) + ) + + send_mock.assert_awaited_once() + _, call_kwargs = send_mock.await_args + assert call_kwargs["thread_id"] is None diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 2700231e95b..591aca1d587 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -18,6 +18,8 @@ logger = logging.getLogger(__name__) _TELEGRAM_TOPIC_TARGET_RE = re.compile(r"^\s*(-?\d+)(?::(\d+))?\s*$") _FEISHU_TARGET_RE = re.compile(r"^\s*((?:oc|ou|on|chat|open)_[-A-Za-z0-9]+)(?::([-A-Za-z0-9_]+))?\s*$") +# Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets. +_NUMERIC_TOPIC_RE = _TELEGRAM_TOPIC_TARGET_RE _IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif"} _VIDEO_EXTS = {".mp4", ".mov", ".avi", ".mkv", ".3gp"} _AUDIO_EXTS = {".ogg", ".opus", ".mp3", ".wav", ".m4a"} @@ -65,7 +67,7 @@ SEND_MESSAGE_SCHEMA = { }, "target": { "type": "string", - "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or Telegram topic 'telegram:chat_id:thread_id'. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:#bot-home', 'slack:#engineering', 'signal:+15551234567'" + "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics and Discord threads. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'signal:+155****4567'" }, "message": { "type": "string", @@ -231,6 +233,10 @@ def _parse_target_ref(platform_name: str, target_ref: str): match = _FEISHU_TARGET_RE.fullmatch(target_ref) if match: return match.group(1), match.group(2), True + if platform_name == "discord": + match = _NUMERIC_TOPIC_RE.fullmatch(target_ref) + if match: + return match.group(1), match.group(2), True if target_ref.lstrip("-").isdigit(): return target_ref, None, True return None, None, False @@ -381,7 +387,7 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = None for chunk in chunks: if platform == Platform.DISCORD: - result = await _send_discord(pconfig.token, chat_id, chunk) + result = await _send_discord(pconfig.token, chat_id, chunk, thread_id=thread_id) elif platform == Platform.SLACK: result = await _send_slack(pconfig.token, chat_id, chunk) elif platform == Platform.WHATSAPP: @@ -545,10 +551,13 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No return _error(f"Telegram send failed: {e}") -async def _send_discord(token, chat_id, message): +async def _send_discord(token, chat_id, message, thread_id=None): """Send a single message via Discord REST API (no websocket client needed). Chunking is handled by _send_to_platform() before this is called. + + When thread_id is provided, the message is sent directly to that thread + via the /channels/{thread_id}/messages endpoint. """ try: import aiohttp @@ -558,7 +567,11 @@ async def _send_discord(token, chat_id, message): from gateway.platforms.base import resolve_proxy_url, proxy_kwargs_for_aiohttp _proxy = resolve_proxy_url(platform_env_var="DISCORD_PROXY") _sess_kw, _req_kw = proxy_kwargs_for_aiohttp(_proxy) - url = f"https://discord.com/api/v10/channels/{chat_id}/messages" + # Thread endpoint: Discord threads are channels; send directly to the thread ID. + if thread_id: + url = f"https://discord.com/api/v10/channels/{thread_id}/messages" + else: + url = f"https://discord.com/api/v10/channels/{chat_id}/messages" headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: async with session.post(url, headers=headers, json={"content": message}, **_req_kw) as resp: