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:
maxyangcn 2026-04-10 03:17:29 -07:00 committed by Teknium
parent 6d5f607e48
commit 19292eb8bf
4 changed files with 229 additions and 12 deletions

View file

@ -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: