Skip to content
Closed
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
35 changes: 35 additions & 0 deletions tests/tools/test_send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
22 changes: 16 additions & 6 deletions tools/send_message_tool.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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:<account_id>' (DM), 'yuanbao:group:<group_code>' (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:<account_id>' (DM), 'yuanbao:group:<group_code>' (group chat)"
},
"message": {
"type": "string",
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand All @@ -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}")
Expand Down