diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index a262d900e..90012e1c7 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1182,7 +1182,8 @@ class BasePlatformAdapter(ABC): """ return content - def truncate_message(self, content: str, max_length: int = 4096) -> List[str]: + @staticmethod + def truncate_message(content: str, max_length: int = 4096) -> List[str]: """ Split a long message into chunks, preserving code block boundaries. diff --git a/tests/tools/test_send_message_tool.py b/tests/tools/test_send_message_tool.py index b5cb33200..7ef9b149d 100644 --- a/tests/tools/test_send_message_tool.py +++ b/tests/tools/test_send_message_tool.py @@ -9,7 +9,7 @@ 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_message_tool +from tools.send_message_tool import _send_telegram, _send_to_platform, send_message_tool def _run_async_immediately(coro): @@ -345,3 +345,49 @@ class TestSendTelegramMediaDelivery: assert "error" in result assert "No deliverable text or media remained" in result["error"] bot.send_message.assert_not_awaited() + + +# --------------------------------------------------------------------------- +# Regression: long messages are chunked before platform dispatch +# --------------------------------------------------------------------------- + + +class TestSendToPlatformChunking: + def test_long_message_is_chunked(self): + """Messages exceeding the platform limit are split into multiple sends.""" + send = AsyncMock(return_value={"success": True, "message_id": "1"}) + long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit + with patch("tools.send_message_tool._send_discord", send): + result = asyncio.run( + _send_to_platform( + Platform.DISCORD, + SimpleNamespace(enabled=True, token="tok", extra={}), + "ch", long_msg, + ) + ) + assert result["success"] is True + assert send.await_count >= 3 + for call in send.await_args_list: + assert len(call.args[2]) <= 2020 # each chunk fits the limit + + def test_telegram_media_attaches_to_last_chunk(self): + """When chunked, media files are sent only with the last chunk.""" + sent_calls = [] + + async def fake_send(token, chat_id, message, media_files=None, thread_id=None): + sent_calls.append(media_files or []) + return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(len(sent_calls))} + + long_msg = "word " * 2000 # ~10000 chars, well over 4096 + media = [("/tmp/photo.png", False)] + with patch("tools.send_message_tool._send_telegram", fake_send): + asyncio.run( + _send_to_platform( + Platform.TELEGRAM, + SimpleNamespace(enabled=True, token="tok", extra={}), + "123", long_msg, media_files=media, + ) + ) + assert len(sent_calls) >= 3 + assert all(call == [] for call in sent_calls[:-1]) + assert sent_calls[-1] == media diff --git a/tools/send_message_tool.py b/tools/send_message_tool.py index 61f77a563..9a404adaa 100644 --- a/tools/send_message_tool.py +++ b/tools/send_message_tool.py @@ -263,18 +263,53 @@ def _maybe_skip_cron_duplicate_send(platform_name: str, chat_id: str, thread_id: async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, media_files=None): - """Route a message to the appropriate platform sender.""" + """Route a message to the appropriate platform sender. + + Long messages are automatically chunked to fit within platform limits + using the same smart-splitting algorithm as the gateway adapters + (preserves code-block boundaries, adds part indicators). + """ from gateway.config import Platform + from gateway.platforms.base import BasePlatformAdapter + from gateway.platforms.telegram import TelegramAdapter + from gateway.platforms.discord import DiscordAdapter + from gateway.platforms.slack import SlackAdapter media_files = media_files or [] + + # Platform message length limits (from adapter class attributes) + _MAX_LENGTHS = { + Platform.TELEGRAM: TelegramAdapter.MAX_MESSAGE_LENGTH, + Platform.DISCORD: DiscordAdapter.MAX_MESSAGE_LENGTH, + Platform.SLACK: SlackAdapter.MAX_MESSAGE_LENGTH, + } + + # Smart-chunk the message to fit within platform limits. + # For short messages or platforms without a known limit this is a no-op. + max_len = _MAX_LENGTHS.get(platform) + if max_len: + chunks = BasePlatformAdapter.truncate_message(message, max_len) + else: + chunks = [message] + + # --- Telegram: special handling for media attachments --- if platform == Platform.TELEGRAM: - return await _send_telegram( - pconfig.token, - chat_id, - message, - media_files=media_files, - thread_id=thread_id, - ) + last_result = None + for i, chunk in enumerate(chunks): + is_last = (i == len(chunks) - 1) + result = await _send_telegram( + pconfig.token, + chat_id, + chunk, + media_files=media_files if is_last else [], + thread_id=thread_id, + ) + if isinstance(result, dict) and result.get("error"): + return result + last_result = result + return last_result + + # --- Non-Telegram platforms --- if media_files and not message.strip(): return { "error": ( @@ -289,22 +324,28 @@ async def _send_to_platform(platform, pconfig, chat_id, message, thread_id=None, "native send_message media delivery is currently only supported for telegram" ) - if platform == Platform.DISCORD: - result = await _send_discord(pconfig.token, chat_id, message) - elif platform == Platform.SLACK: - result = await _send_slack(pconfig.token, chat_id, message) - elif platform == Platform.SIGNAL: - result = await _send_signal(pconfig.extra, chat_id, message) - elif platform == Platform.EMAIL: - result = await _send_email(pconfig.extra, chat_id, message) - else: - result = {"error": f"Direct sending not yet implemented for {platform.value}"} + last_result = None + for chunk in chunks: + if platform == Platform.DISCORD: + result = await _send_discord(pconfig.token, chat_id, chunk) + elif platform == Platform.SLACK: + result = await _send_slack(pconfig.token, chat_id, chunk) + elif platform == Platform.SIGNAL: + result = await _send_signal(pconfig.extra, chat_id, chunk) + elif platform == Platform.EMAIL: + result = await _send_email(pconfig.extra, chat_id, chunk) + else: + result = {"error": f"Direct sending not yet implemented for {platform.value}"} - if warning and isinstance(result, dict) and result.get("success"): - warnings = list(result.get("warnings", [])) + if isinstance(result, dict) and result.get("error"): + return result + last_result = result + + if warning and isinstance(last_result, dict) and last_result.get("success"): + warnings = list(last_result.get("warnings", [])) warnings.append(warning) - result["warnings"] = warnings - return result + last_result["warnings"] = warnings + return last_result async def _send_telegram(token, chat_id, message, media_files=None, thread_id=None): @@ -415,7 +456,10 @@ async def _send_telegram(token, chat_id, message, media_files=None, thread_id=No async def _send_discord(token, chat_id, message): - """Send via Discord REST API (no websocket client needed).""" + """Send a single message via Discord REST API (no websocket client needed). + + Chunking is handled by _send_to_platform() before this is called. + """ try: import aiohttp except ImportError: @@ -423,17 +467,13 @@ async def _send_discord(token, chat_id, message): try: url = f"https://discord.com/api/v10/channels/{chat_id}/messages" headers = {"Authorization": f"Bot {token}", "Content-Type": "application/json"} - chunks = [message[i:i+2000] for i in range(0, len(message), 2000)] - message_ids = [] async with aiohttp.ClientSession() as session: - for chunk in chunks: - async with session.post(url, headers=headers, json={"content": chunk}) as resp: - if resp.status not in (200, 201): - body = await resp.text() - return {"error": f"Discord API error ({resp.status}): {body}"} - data = await resp.json() - message_ids.append(data.get("id")) - return {"success": True, "platform": "discord", "chat_id": chat_id, "message_ids": message_ids} + async with session.post(url, headers=headers, json={"content": message}) as resp: + if resp.status not in (200, 201): + body = await resp.text() + return {"error": f"Discord API error ({resp.status}): {body}"} + data = await resp.json() + return {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": data.get("id")} except Exception as e: return {"error": f"Discord send failed: {e}"}