diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index fa810eb5c54d..4965c59c9031 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -143,6 +143,41 @@ def test_resolved_telegram_topic_name_preserves_thread_id(self): force_document=False, ) + def test_slack_thread_target_preserves_thread_ts(self): + chat_id, thread_id, is_explicit = _parse_target_ref( + "slack", + "C0AUUMPP7D1:1778566499.343239", + ) + + assert chat_id == "C0AUUMPP7D1" + assert thread_id == "1778566499.343239" + assert is_explicit is True + + def test_send_to_platform_passes_slack_thread_id(self): + slack_cfg = SimpleNamespace(enabled=True, token="test", extra={}) + + async def _run(): + with patch("tools.send_message_tool._send_slack", new=AsyncMock(return_value={"success": True})) as slack_send: + result = await _send_to_platform( + Platform.SLACK, + slack_cfg, + "C0AUUMPP7D1", + "hello", + thread_id="1778566499.343239", + media_files=[], + force_document=False, + ) + slack_send.assert_awaited_once_with( + "test", + "C0AUUMPP7D1", + "hello", + thread_id="1778566499.343239", + ) + return result + + result = asyncio.run(_run()) + assert result["success"] is True + def test_display_label_target_resolves_via_channel_directory(self, tmp_path): config, telegram_cfg = _make_config() cache_file = tmp_path / "channel_directory.json" diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index c8d84fdf213d..76b1cbde117f 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -27,7 +27,9 @@ # because the API requires a conversation ID. To DM a user you must first call # conversations.open to obtain a D... ID. Without this gate, Slack IDs fall # through to channel-name resolution, which only matches by name and fails. -_SLACK_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,})\s*$") +# Optional suffix supports threaded replies via Slack thread_ts, e.g. +# slack:C0123456789:1778566499.343239. +_SLACK_TARGET_RE = re.compile(r"^\s*([CGD][A-Z0-9]{8,})(?::(\d{10,}(?:\.\d+)?))?\s*$") _WEIXIN_TARGET_RE = re.compile(r"^\s*((?:wxid|gh|v\d+|wm|wb)_[A-Za-z0-9_-]+|[A-Za-z0-9._-]+@chatroom|filehelper)\s*$") _YUANBAO_TARGET_RE = re.compile(r"^\s*((?:group|direct):[^:]+)\s*$") # Discord snowflake IDs are numeric, same regex pattern as Telegram topic targets. @@ -133,7 +135,7 @@ async def _send_telegram_message_with_retry(bot, *, attempts: int = 3, **kwargs) }, "target": { "type": "string", - "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', 'matrix:!roomid:server.org', 'matrix:@user:server.org', 'yuanbao:direct:' (DM), 'yuanbao:group:' (group chat)" + "description": "Delivery target. Format: 'platform' (uses home channel), 'platform:#channel-name', 'platform:chat_id', or 'platform:chat_id:thread_id' for Telegram topics, Discord threads, and Slack thread_ts replies. Examples: 'telegram', 'telegram:-1001234567890:17585', 'discord:999888777:555444333', 'discord:#bot-home', 'slack:#engineering', 'slack:C0123456789:1778566499.343239', 'signal:+155****4567', 'matrix:!roomid:server.org', 'matrix:@user:server.org', 'yuanbao:direct:' (DM), 'yuanbao:group:' (group chat)" }, "message": { "type": "string", @@ -332,7 +334,7 @@ def _parse_target_ref(platform_name: str, target_ref: str): if platform_name == "slack": match = _SLACK_TARGET_RE.fullmatch(target_ref) if match: - return match.group(1), None, True + return match.group(1), match.group(2), True if platform_name == "weixin": match = _WEIXIN_TARGET_RE.fullmatch(target_ref) if match: @@ -699,7 +701,10 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, last_result = None for chunk in chunks: if platform == Platform.SLACK: - result = await _send_slack(pconfig.token, chat_id, chunk) + if thread_id is not None: + result = await _send_slack(pconfig.token, chat_id, chunk, thread_id=thread_id) + else: + result = await _send_slack(pconfig.token, chat_id, chunk) elif platform == Platform.WHATSAPP: result = await _send_whatsapp(pconfig.extra, chat_id, chunk) elif platform == Platform.SIGNAL: @@ -1120,7 +1125,7 @@ async def _send_discord(token, chat_id, message, thread_id=None, media_files=Non return _error(f"Discord send failed: {e}") -async def _send_slack(token, chat_id, message): +async def _send_slack(token, chat_id, message, thread_id=None): """Send via Slack Web API.""" try: import aiohttp @@ -1134,10 +1139,15 @@ async def _send_slack(token, chat_id, message): headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"} async with aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=30), **_sess_kw) as session: payload = {"channel": chat_id, "text": message, "mrkdwn": True} + if thread_id: + payload["thread_ts"] = str(thread_id) async with session.post(url, headers=headers, json=payload, **_req_kw) as resp: data = await resp.json() if data.get("ok"): - return {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")} + result = {"success": True, "platform": "slack", "chat_id": chat_id, "message_id": data.get("ts")} + if thread_id: + result["thread_id"] = str(thread_id) + return result return _error(f"Slack API error: {data.get('error', 'unknown')}") except Exception as e: return _error(f"Slack send failed: {e}")