diff --git a/gateway/display_config.py b/gateway/display_config.py index e148be910..9375266ca 100644 --- a/gateway/display_config.py +++ b/gateway/display_config.py @@ -82,7 +82,7 @@ _PLATFORM_DEFAULTS: dict[str, dict[str, Any]] = { # Tier 3 — no edit support, progress messages are permanent "signal": _TIER_LOW, - "whatsapp": _TIER_LOW, + "whatsapp": _TIER_MEDIUM, # Baileys bridge supports /edit "bluebubbles": _TIER_LOW, "weixin": _TIER_LOW, "wecom": _TIER_LOW, diff --git a/gateway/platforms/whatsapp.py b/gateway/platforms/whatsapp.py index c616f7244..d1de5b856 100644 --- a/gateway/platforms/whatsapp.py +++ b/gateway/platforms/whatsapp.py @@ -120,8 +120,9 @@ class WhatsAppAdapter(BasePlatformAdapter): - session_path: Path to store WhatsApp session data """ - # WhatsApp message limits - MAX_MESSAGE_LENGTH = 65536 # WhatsApp allows longer messages + # WhatsApp message limits — practical UX limit, not protocol max. + # WhatsApp allows ~65K but long messages are unreadable on mobile. + MAX_MESSAGE_LENGTH = 4096 # Default bridge location relative to the hermes-agent install _DEFAULT_BRIDGE_DIR = Path(__file__).resolve().parents[2] / "scripts" / "whatsapp-bridge" @@ -531,6 +532,63 @@ class WhatsAppAdapter(BasePlatformAdapter): self._close_bridge_log() print(f"[{self.name}] Disconnected") + def format_message(self, content: str) -> str: + """Convert standard markdown to WhatsApp-compatible formatting. + + WhatsApp supports: *bold*, _italic_, ~strikethrough~, ```code```, + and monospaced `inline`. Standard markdown uses different syntax + for bold/italic/strikethrough, so we convert here. + + Code blocks (``` fenced) and inline code (`) are protected from + conversion via placeholder substitution. + """ + if not content: + return content + + # --- 1. Protect fenced code blocks from formatting changes --- + _FENCE_PH = "\x00FENCE" + fences: list[str] = [] + + def _save_fence(m: re.Match) -> str: + fences.append(m.group(0)) + return f"{_FENCE_PH}{len(fences) - 1}\x00" + + result = re.sub(r"```[\s\S]*?```", _save_fence, content) + + # --- 2. Protect inline code --- + _CODE_PH = "\x00CODE" + codes: list[str] = [] + + def _save_code(m: re.Match) -> str: + codes.append(m.group(0)) + return f"{_CODE_PH}{len(codes) - 1}\x00" + + result = re.sub(r"`[^`\n]+`", _save_code, result) + + # --- 3. Convert markdown formatting to WhatsApp syntax --- + # Bold: **text** or __text__ → *text* + result = re.sub(r"\*\*(.+?)\*\*", r"*\1*", result) + result = re.sub(r"__(.+?)__", r"*\1*", result) + # Strikethrough: ~~text~~ → ~text~ + result = re.sub(r"~~(.+?)~~", r"~\1~", result) + # Italic: *text* is already WhatsApp italic — leave as-is + # _text_ is already WhatsApp italic — leave as-is + + # --- 4. Convert markdown headers to bold text --- + # # Header → *Header* + result = re.sub(r"^#{1,6}\s+(.+)$", r"*\1*", result, flags=re.MULTILINE) + + # --- 5. Convert markdown links: [text](url) → text (url) --- + result = re.sub(r"\[([^\]]+)\]\(([^)]+)\)", r"\1 (\2)", result) + + # --- 6. Restore protected sections --- + for i, fence in enumerate(fences): + result = result.replace(f"{_FENCE_PH}{i}\x00", fence) + for i, code in enumerate(codes): + result = result.replace(f"{_CODE_PH}{i}\x00", code) + + return result + async def send( self, chat_id: str, @@ -538,38 +596,57 @@ class WhatsAppAdapter(BasePlatformAdapter): reply_to: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None ) -> SendResult: - """Send a message via the WhatsApp bridge.""" + """Send a message via the WhatsApp bridge. + + Formats markdown for WhatsApp, splits long messages into chunks + that preserve code block boundaries, and sends each chunk sequentially. + """ if not self._running or not self._http_session: return SendResult(success=False, error="Not connected") bridge_exit = await self._check_managed_bridge_exit() if bridge_exit: return SendResult(success=False, error=bridge_exit) - + + if not content or not content.strip(): + return SendResult(success=True, message_id=None) + try: import aiohttp - payload = { - "chatId": chat_id, - "message": content, - } - if reply_to: - payload["replyTo"] = reply_to - - async with self._http_session.post( - f"http://127.0.0.1:{self._bridge_port}/send", - json=payload, - timeout=aiohttp.ClientTimeout(total=30) - ) as resp: - if resp.status == 200: - data = await resp.json() - return SendResult( - success=True, - message_id=data.get("messageId"), - raw_response=data - ) - else: - error = await resp.text() - return SendResult(success=False, error=error) + # Format and chunk the message + formatted = self.format_message(content) + chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH) + + last_message_id = None + for chunk in chunks: + payload: Dict[str, Any] = { + "chatId": chat_id, + "message": chunk, + } + if reply_to and last_message_id is None: + # Only reply-to on the first chunk + payload["replyTo"] = reply_to + + async with self._http_session.post( + f"http://127.0.0.1:{self._bridge_port}/send", + json=payload, + timeout=aiohttp.ClientTimeout(total=30) + ) as resp: + if resp.status == 200: + data = await resp.json() + last_message_id = data.get("messageId") + else: + error = await resp.text() + return SendResult(success=False, error=error) + + # Small delay between chunks to avoid rate limiting + if len(chunks) > 1: + await asyncio.sleep(0.3) + + return SendResult( + success=True, + message_id=last_message_id, + ) except Exception as e: return SendResult(success=False, error=str(e)) diff --git a/tests/gateway/test_display_config.py b/tests/gateway/test_display_config.py index 4dd73ebd2..c9ad51280 100644 --- a/tests/gateway/test_display_config.py +++ b/tests/gateway/test_display_config.py @@ -189,14 +189,14 @@ class TestPlatformDefaults: """Slack, Mattermost, Matrix default to 'new' tool progress.""" from gateway.display_config import resolve_display_setting - for plat in ("slack", "mattermost", "matrix", "feishu"): + for plat in ("slack", "mattermost", "matrix", "feishu", "whatsapp"): assert resolve_display_setting({}, plat, "tool_progress") == "new", plat def test_low_tier_platforms(self): - """Signal, WhatsApp, etc. default to 'off' tool progress.""" + """Signal, BlueBubbles, etc. default to 'off' tool progress.""" from gateway.display_config import resolve_display_setting - for plat in ("signal", "whatsapp", "bluebubbles", "weixin", "wecom", "dingtalk"): + for plat in ("signal", "bluebubbles", "weixin", "wecom", "dingtalk"): assert resolve_display_setting({}, plat, "tool_progress") == "off", plat def test_minimal_tier_platforms(self): diff --git a/tests/gateway/test_whatsapp_formatting.py b/tests/gateway/test_whatsapp_formatting.py new file mode 100644 index 000000000..129384783 --- /dev/null +++ b/tests/gateway/test_whatsapp_formatting.py @@ -0,0 +1,271 @@ +"""Tests for WhatsApp message formatting and chunking. + +Covers: +- format_message(): markdown → WhatsApp syntax conversion +- send(): message chunking for long responses +- MAX_MESSAGE_LENGTH: practical UX limit +""" + +import asyncio +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import Platform, PlatformConfig + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _make_adapter(): + """Create a WhatsAppAdapter with test attributes (bypass __init__).""" + from gateway.platforms.whatsapp import WhatsAppAdapter + + adapter = WhatsAppAdapter.__new__(WhatsAppAdapter) + adapter.platform = Platform.WHATSAPP + adapter.config = MagicMock() + adapter.config.extra = {} + adapter._bridge_port = 3000 + adapter._bridge_script = "/tmp/test-bridge.js" + adapter._session_path = MagicMock() + adapter._bridge_log_fh = None + adapter._bridge_log = None + adapter._bridge_process = None + adapter._reply_prefix = None + adapter._running = True + adapter._message_handler = None + adapter._fatal_error_code = None + adapter._fatal_error_message = None + adapter._fatal_error_retryable = True + adapter._fatal_error_handler = None + adapter._active_sessions = {} + adapter._pending_messages = {} + adapter._background_tasks = set() + adapter._auto_tts_disabled_chats = set() + adapter._message_queue = asyncio.Queue() + adapter._http_session = MagicMock() + adapter._mention_patterns = [] + return adapter + + +class _AsyncCM: + """Minimal async context manager returning a fixed value.""" + + def __init__(self, value): + self.value = value + + async def __aenter__(self): + return self.value + + async def __aexit__(self, *exc): + return False + + +# --------------------------------------------------------------------------- +# format_message tests +# --------------------------------------------------------------------------- + +class TestFormatMessage: + """WhatsApp markdown conversion.""" + + def test_bold_double_asterisk(self): + adapter = _make_adapter() + assert adapter.format_message("**hello**") == "*hello*" + + def test_bold_double_underscore(self): + adapter = _make_adapter() + assert adapter.format_message("__hello__") == "*hello*" + + def test_strikethrough(self): + adapter = _make_adapter() + assert adapter.format_message("~~deleted~~") == "~deleted~" + + def test_headers_converted_to_bold(self): + adapter = _make_adapter() + assert adapter.format_message("# Title") == "*Title*" + assert adapter.format_message("## Subtitle") == "*Subtitle*" + assert adapter.format_message("### Deep") == "*Deep*" + + def test_links_converted(self): + adapter = _make_adapter() + result = adapter.format_message("[click here](https://example.com)") + assert result == "click here (https://example.com)" + + def test_code_blocks_protected(self): + """Code blocks should not have their content reformatted.""" + adapter = _make_adapter() + content = "before **bold** ```python\n**not bold**\n``` after **bold**" + result = adapter.format_message(content) + assert "```python\n**not bold**\n```" in result + assert result.startswith("before *bold*") + assert result.endswith("after *bold*") + + def test_inline_code_protected(self): + """Inline code should not have its content reformatted.""" + adapter = _make_adapter() + content = "use `**raw**` here" + result = adapter.format_message(content) + assert "`**raw**`" in result + assert result.startswith("use ") + + def test_empty_content(self): + adapter = _make_adapter() + assert adapter.format_message("") == "" + assert adapter.format_message(None) is None + + def test_plain_text_unchanged(self): + adapter = _make_adapter() + assert adapter.format_message("hello world") == "hello world" + + def test_already_whatsapp_italic(self): + """Single *italic* should pass through unchanged.""" + adapter = _make_adapter() + # After bold conversion, *text* is WhatsApp italic + assert adapter.format_message("*italic*") == "*italic*" + + def test_multiline_mixed(self): + adapter = _make_adapter() + content = "# Header\n\n**Bold text** and ~~strike~~\n\n```\ncode\n```" + result = adapter.format_message(content) + assert "*Header*" in result + assert "*Bold text*" in result + assert "~strike~" in result + assert "```\ncode\n```" in result + + +# --------------------------------------------------------------------------- +# MAX_MESSAGE_LENGTH tests +# --------------------------------------------------------------------------- + +class TestMessageLimits: + """WhatsApp message length limits.""" + + def test_max_message_length_is_practical(self): + from gateway.platforms.whatsapp import WhatsAppAdapter + assert WhatsAppAdapter.MAX_MESSAGE_LENGTH == 4096 + + +# --------------------------------------------------------------------------- +# send() chunking tests +# --------------------------------------------------------------------------- + +class TestSendChunking: + """WhatsApp send() splits long messages into chunks.""" + + @pytest.mark.asyncio + async def test_short_message_single_send(self): + adapter = _make_adapter() + resp = MagicMock(status=200) + resp.json = AsyncMock(return_value={"messageId": "msg1"}) + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + result = await adapter.send("chat1", "short message") + assert result.success + # Only one call to bridge /send + assert adapter._http_session.post.call_count == 1 + + @pytest.mark.asyncio + async def test_long_message_chunked(self): + adapter = _make_adapter() + resp = MagicMock(status=200) + resp.json = AsyncMock(return_value={"messageId": "msg1"}) + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + # Create a message longer than MAX_MESSAGE_LENGTH (4096) + long_msg = "a " * 3000 # ~6000 chars + + result = await adapter.send("chat1", long_msg) + assert result.success + # Should have made multiple calls + assert adapter._http_session.post.call_count > 1 + + @pytest.mark.asyncio + async def test_empty_message_no_send(self): + adapter = _make_adapter() + result = await adapter.send("chat1", "") + assert result.success + assert adapter._http_session.post.call_count == 0 + + @pytest.mark.asyncio + async def test_whitespace_only_no_send(self): + adapter = _make_adapter() + result = await adapter.send("chat1", " \n ") + assert result.success + assert adapter._http_session.post.call_count == 0 + + @pytest.mark.asyncio + async def test_format_applied_before_send(self): + """Markdown should be converted to WhatsApp format before sending.""" + adapter = _make_adapter() + resp = MagicMock(status=200) + resp.json = AsyncMock(return_value={"messageId": "msg1"}) + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + await adapter.send("chat1", "**bold text**") + + # Check the payload sent to the bridge + call_args = adapter._http_session.post.call_args + payload = call_args.kwargs.get("json") or call_args[1].get("json") + assert payload["message"] == "*bold text*" + + @pytest.mark.asyncio + async def test_reply_to_only_on_first_chunk(self): + """reply_to should only be set on the first chunk.""" + adapter = _make_adapter() + resp = MagicMock(status=200) + resp.json = AsyncMock(return_value={"messageId": "msg1"}) + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + long_msg = "word " * 2000 # ~10000 chars, multiple chunks + + await adapter.send("chat1", long_msg, reply_to="orig123") + + calls = adapter._http_session.post.call_args_list + assert len(calls) > 1 + + # First chunk should have replyTo + first_payload = calls[0].kwargs.get("json") or calls[0][1].get("json") + assert first_payload.get("replyTo") == "orig123" + + # Subsequent chunks should NOT have replyTo + for call in calls[1:]: + payload = call.kwargs.get("json") or call[1].get("json") + assert "replyTo" not in payload + + @pytest.mark.asyncio + async def test_bridge_error_returns_failure(self): + adapter = _make_adapter() + resp = MagicMock(status=500) + resp.text = AsyncMock(return_value="Internal Server Error") + adapter._http_session.post = MagicMock(return_value=_AsyncCM(resp)) + + result = await adapter.send("chat1", "hello") + assert not result.success + assert "Internal Server Error" in result.error + + @pytest.mark.asyncio + async def test_not_connected_returns_failure(self): + adapter = _make_adapter() + adapter._running = False + + result = await adapter.send("chat1", "hello") + assert not result.success + assert "Not connected" in result.error + + +# --------------------------------------------------------------------------- +# display_config tier classification +# --------------------------------------------------------------------------- + +class TestWhatsAppTier: + """WhatsApp should be classified as TIER_MEDIUM.""" + + def test_whatsapp_streaming_follows_global(self): + from gateway.display_config import resolve_display_setting + # TIER_MEDIUM has streaming: None (follow global), not False + assert resolve_display_setting({}, "whatsapp", "streaming") is None + + def test_whatsapp_tool_progress_is_new(self): + from gateway.display_config import resolve_display_setting + assert resolve_display_setting({}, "whatsapp", "tool_progress") == "new"