mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-04-25 00:51:20 +00:00
feat(cron): support Discord thread_id in deliver targets
Add Discord thread support to cron delivery and send_message_tool.
- _parse_target_ref: handle discord platform with chat_id:thread_id format
- _send_discord: add thread_id param, route to /channels/{thread_id}/messages
- _send_to_platform: pass thread_id through for Discord
- Discord adapter send(): read thread_id from metadata for gateway path
- Update tool schema description to document Discord thread targets
Cherry-picked from PR #7046 by pandacooming (maxyangcn).
Follow-up fixes:
- Restore proxy support (resolve_proxy_url/proxy_kwargs_for_aiohttp) that was
accidentally deleted — would have caused NameError at runtime
- Remove duplicate _DISCORD_TARGET_RE regex; reuse existing _TELEGRAM_TOPIC_TARGET_RE
via _NUMERIC_TOPIC_RE alias (identical pattern)
- Fix misleading test comments about Discord negative snowflake IDs
(Discord uses positive snowflakes; negative IDs are a Telegram convention)
- Rewrite misleading scheduler test that claimed to exercise home channel
fallback but actually tested the explicit platform:chat_id parsing path
This commit is contained in:
parent
6d5f607e48
commit
19292eb8bf
4 changed files with 229 additions and 12 deletions
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue