test_send_message_tool.py
1 """Tests for tools/send_message_tool.py.""" 2 3 import asyncio 4 import json 5 import os 6 import sys 7 from pathlib import Path 8 from types import SimpleNamespace 9 from unittest.mock import AsyncMock, MagicMock, patch 10 11 import pytest 12 13 14 @pytest.fixture(autouse=True) 15 def _reset_signal_scheduler(): 16 """Drop the process-wide attachment scheduler so each test gets a 17 fresh token bucket.""" 18 from gateway.platforms.signal_rate_limit import _reset_scheduler 19 _reset_scheduler() 20 yield 21 _reset_scheduler() 22 23 from gateway.config import Platform 24 from tools.send_message_tool import ( 25 _derive_forum_thread_name, 26 _parse_target_ref, 27 _send_discord, 28 _send_matrix_via_adapter, 29 _send_signal, 30 _send_telegram, 31 _send_to_platform, 32 send_message_tool, 33 ) 34 35 36 def _run_async_immediately(coro): 37 return asyncio.run(coro) 38 39 40 def _make_config(): 41 telegram_cfg = SimpleNamespace(enabled=True, token="***", extra={}) 42 return SimpleNamespace( 43 platforms={Platform.TELEGRAM: telegram_cfg}, 44 get_home_channel=lambda _platform: None, 45 ), telegram_cfg 46 47 48 def _install_telegram_mock(monkeypatch, bot): 49 parse_mode = SimpleNamespace(MARKDOWN_V2="MarkdownV2", HTML="HTML") 50 constants_mod = SimpleNamespace(ParseMode=parse_mode) 51 telegram_mod = SimpleNamespace(Bot=lambda token: bot, constants=constants_mod) 52 monkeypatch.setitem(sys.modules, "telegram", telegram_mod) 53 monkeypatch.setitem(sys.modules, "telegram.constants", constants_mod) 54 55 56 def _ensure_slack_mock(monkeypatch): 57 if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"): 58 return 59 60 slack_bolt = MagicMock() 61 slack_bolt.async_app.AsyncApp = MagicMock 62 slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock 63 64 slack_sdk = MagicMock() 65 slack_sdk.web.async_client.AsyncWebClient = MagicMock 66 67 for name, mod in [ 68 ("slack_bolt", slack_bolt), 69 ("slack_bolt.async_app", slack_bolt.async_app), 70 ("slack_bolt.adapter", slack_bolt.adapter), 71 ("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode), 72 ("slack_bolt.adapter.socket_mode.async_handler", slack_bolt.adapter.socket_mode.async_handler), 73 ("slack_sdk", slack_sdk), 74 ("slack_sdk.web", slack_sdk.web), 75 ("slack_sdk.web.async_client", slack_sdk.web.async_client), 76 ]: 77 monkeypatch.setitem(sys.modules, name, mod) 78 79 80 class TestSendMessageTool: 81 def test_cron_duplicate_target_is_skipped_and_explained(self): 82 home = SimpleNamespace(chat_id="-1001") 83 config, _telegram_cfg = _make_config() 84 config.get_home_channel = lambda _platform: home 85 86 with patch.dict( 87 os.environ, 88 { 89 "HERMES_CRON_AUTO_DELIVER_PLATFORM": "telegram", 90 "HERMES_CRON_AUTO_DELIVER_CHAT_ID": "-1001", 91 }, 92 clear=False, 93 ), \ 94 patch("gateway.config.load_gateway_config", return_value=config), \ 95 patch("tools.interrupt.is_interrupted", return_value=False), \ 96 patch("model_tools._run_async", side_effect=_run_async_immediately), \ 97 patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ 98 patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock: 99 result = json.loads( 100 send_message_tool( 101 { 102 "action": "send", 103 "target": "telegram", 104 "message": "hello", 105 } 106 ) 107 ) 108 109 assert result["success"] is True 110 assert result["skipped"] is True 111 assert result["reason"] == "cron_auto_delivery_duplicate_target" 112 assert "final response" in result["note"] 113 send_mock.assert_not_awaited() 114 mirror_mock.assert_not_called() 115 116 def test_resolved_telegram_topic_name_preserves_thread_id(self): 117 config, telegram_cfg = _make_config() 118 119 with patch("gateway.config.load_gateway_config", return_value=config), \ 120 patch("tools.interrupt.is_interrupted", return_value=False), \ 121 patch("gateway.channel_directory.resolve_channel_name", return_value="-1001:17585"), \ 122 patch("model_tools._run_async", side_effect=_run_async_immediately), \ 123 patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ 124 patch("gateway.mirror.mirror_to_session", return_value=True): 125 result = json.loads( 126 send_message_tool( 127 { 128 "action": "send", 129 "target": "telegram:Coaching Chat / topic 17585", 130 "message": "hello", 131 } 132 ) 133 ) 134 135 assert result["success"] is True 136 send_mock.assert_awaited_once_with( 137 Platform.TELEGRAM, 138 telegram_cfg, 139 "-1001", 140 "hello", 141 thread_id="17585", 142 media_files=[], 143 ) 144 145 def test_display_label_target_resolves_via_channel_directory(self, tmp_path): 146 config, telegram_cfg = _make_config() 147 cache_file = tmp_path / "channel_directory.json" 148 cache_file.write_text(json.dumps({ 149 "updated_at": "2026-01-01T00:00:00", 150 "platforms": { 151 "telegram": [ 152 {"id": "-1001:17585", "name": "Coaching Chat / topic 17585", "type": "group"} 153 ] 154 }, 155 })) 156 157 with patch("gateway.channel_directory.DIRECTORY_PATH", cache_file), \ 158 patch("gateway.config.load_gateway_config", return_value=config), \ 159 patch("tools.interrupt.is_interrupted", return_value=False), \ 160 patch("model_tools._run_async", side_effect=_run_async_immediately), \ 161 patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})) as send_mock, \ 162 patch("gateway.mirror.mirror_to_session", return_value=True): 163 result = json.loads( 164 send_message_tool( 165 { 166 "action": "send", 167 "target": "telegram:Coaching Chat / topic 17585 (group)", 168 "message": "hello", 169 } 170 ) 171 ) 172 173 assert result["success"] is True 174 send_mock.assert_awaited_once_with( 175 Platform.TELEGRAM, 176 telegram_cfg, 177 "-1001", 178 "hello", 179 thread_id="17585", 180 media_files=[], 181 ) 182 183 def test_mirror_receives_current_session_user_id(self): 184 config, _telegram_cfg = _make_config() 185 186 with patch("gateway.config.load_gateway_config", return_value=config), \ 187 patch("tools.interrupt.is_interrupted", return_value=False), \ 188 patch("model_tools._run_async", side_effect=_run_async_immediately), \ 189 patch("tools.send_message_tool._send_to_platform", new=AsyncMock(return_value={"success": True})), \ 190 patch("gateway.session_context.get_session_env") as get_session_env_mock, \ 191 patch("gateway.mirror.mirror_to_session", return_value=True) as mirror_mock: 192 get_session_env_mock.side_effect = lambda name, default="": { 193 "HERMES_SESSION_PLATFORM": "telegram", 194 "HERMES_SESSION_USER_ID": "user-123", 195 }.get(name, default) 196 result = json.loads( 197 send_message_tool( 198 { 199 "action": "send", 200 "target": "telegram:12345", 201 "message": "hello", 202 } 203 ) 204 ) 205 206 assert result["success"] is True 207 mirror_mock.assert_called_once_with( 208 "telegram", 209 "12345", 210 "hello", 211 source_label="telegram", 212 thread_id=None, 213 user_id="user-123", 214 ) 215 216 def test_top_level_send_failure_redacts_query_token(self): 217 config, _telegram_cfg = _make_config() 218 leaked = "very-secret-query-token-123456" 219 220 def _raise_and_close(coro): 221 coro.close() 222 raise RuntimeError( 223 f"transport error: https://api.example.com/send?access_token={leaked}" 224 ) 225 226 with patch("gateway.config.load_gateway_config", return_value=config), \ 227 patch("tools.interrupt.is_interrupted", return_value=False), \ 228 patch("model_tools._run_async", side_effect=_raise_and_close): 229 result = json.loads( 230 send_message_tool( 231 { 232 "action": "send", 233 "target": "telegram:-1001", 234 "message": "hello", 235 } 236 ) 237 ) 238 239 assert "error" in result 240 assert leaked not in result["error"] 241 assert "access_token=***" in result["error"] 242 243 244 class TestSendTelegramMediaDelivery: 245 def test_sends_text_then_photo_for_media_tag(self, tmp_path, monkeypatch): 246 image_path = tmp_path / "photo.png" 247 image_path.write_bytes(b"\x89PNG\r\n\x1a\n" + b"\x00" * 32) 248 249 bot = MagicMock() 250 bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1)) 251 bot.send_photo = AsyncMock(return_value=SimpleNamespace(message_id=2)) 252 bot.send_video = AsyncMock() 253 bot.send_voice = AsyncMock() 254 bot.send_audio = AsyncMock() 255 bot.send_document = AsyncMock() 256 _install_telegram_mock(monkeypatch, bot) 257 258 result = asyncio.run( 259 _send_telegram( 260 "token", 261 "12345", 262 "Hello there", 263 media_files=[(str(image_path), False)], 264 ) 265 ) 266 267 assert result["success"] is True 268 assert result["message_id"] == "2" 269 bot.send_message.assert_awaited_once() 270 bot.send_photo.assert_awaited_once() 271 sent_text = bot.send_message.await_args.kwargs["text"] 272 assert "MEDIA:" not in sent_text 273 assert sent_text == "Hello there" 274 275 def test_sends_voice_for_ogg_with_voice_directive(self, tmp_path, monkeypatch): 276 voice_path = tmp_path / "voice.ogg" 277 voice_path.write_bytes(b"OggS" + b"\x00" * 32) 278 279 bot = MagicMock() 280 bot.send_message = AsyncMock() 281 bot.send_photo = AsyncMock() 282 bot.send_video = AsyncMock() 283 bot.send_voice = AsyncMock(return_value=SimpleNamespace(message_id=7)) 284 bot.send_audio = AsyncMock() 285 bot.send_document = AsyncMock() 286 _install_telegram_mock(monkeypatch, bot) 287 288 result = asyncio.run( 289 _send_telegram( 290 "token", 291 "12345", 292 "", 293 media_files=[(str(voice_path), True)], 294 ) 295 ) 296 297 assert result["success"] is True 298 bot.send_voice.assert_awaited_once() 299 bot.send_audio.assert_not_awaited() 300 bot.send_message.assert_not_awaited() 301 302 def test_sends_audio_for_mp3(self, tmp_path, monkeypatch): 303 audio_path = tmp_path / "clip.mp3" 304 audio_path.write_bytes(b"ID3" + b"\x00" * 32) 305 306 bot = MagicMock() 307 bot.send_message = AsyncMock() 308 bot.send_photo = AsyncMock() 309 bot.send_video = AsyncMock() 310 bot.send_voice = AsyncMock() 311 bot.send_audio = AsyncMock(return_value=SimpleNamespace(message_id=8)) 312 bot.send_document = AsyncMock() 313 _install_telegram_mock(monkeypatch, bot) 314 315 result = asyncio.run( 316 _send_telegram( 317 "token", 318 "12345", 319 "", 320 media_files=[(str(audio_path), False)], 321 ) 322 ) 323 324 assert result["success"] is True 325 bot.send_audio.assert_awaited_once() 326 bot.send_voice.assert_not_awaited() 327 328 def test_missing_media_returns_error_without_leaking_raw_tag(self, monkeypatch): 329 bot = MagicMock() 330 bot.send_message = AsyncMock() 331 bot.send_photo = AsyncMock() 332 bot.send_video = AsyncMock() 333 bot.send_voice = AsyncMock() 334 bot.send_audio = AsyncMock() 335 bot.send_document = AsyncMock() 336 _install_telegram_mock(monkeypatch, bot) 337 338 result = asyncio.run( 339 _send_telegram( 340 "token", 341 "12345", 342 "", 343 media_files=[("/tmp/does-not-exist.png", False)], 344 ) 345 ) 346 347 assert "error" in result 348 assert "No deliverable text or media remained" in result["error"] 349 bot.send_message.assert_not_awaited() 350 351 352 # --------------------------------------------------------------------------- 353 # Regression: long messages are chunked before platform dispatch 354 # --------------------------------------------------------------------------- 355 356 357 class TestSendToPlatformChunking: 358 def test_long_message_is_chunked(self): 359 """Messages exceeding the platform limit are split into multiple sends.""" 360 send = AsyncMock(return_value={"success": True, "message_id": "1"}) 361 long_msg = "word " * 1000 # ~5000 chars, well over Discord's 2000 limit 362 with patch("tools.send_message_tool._send_discord", send): 363 result = asyncio.run( 364 _send_to_platform( 365 Platform.DISCORD, 366 SimpleNamespace(enabled=True, token="***", extra={}), 367 "ch", long_msg, 368 ) 369 ) 370 assert result["success"] is True 371 assert send.await_count >= 3 372 for call in send.await_args_list: 373 assert len(call.args[2]) <= 2020 # each chunk fits the limit 374 375 def test_slack_messages_are_formatted_before_send(self, monkeypatch): 376 _ensure_slack_mock(monkeypatch) 377 378 import gateway.platforms.slack as slack_mod 379 380 monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) 381 send = AsyncMock(return_value={"success": True, "message_id": "1"}) 382 383 with patch("tools.send_message_tool._send_slack", send): 384 result = asyncio.run( 385 _send_to_platform( 386 Platform.SLACK, 387 SimpleNamespace(enabled=True, token="***", extra={}), 388 "C123", 389 "**hello** from [Hermes](<https://example.com>)", 390 ) 391 ) 392 393 assert result["success"] is True 394 send.assert_awaited_once_with( 395 "***", 396 "C123", 397 "*hello* from <https://example.com|Hermes>", 398 ) 399 400 def test_slack_bold_italic_formatted_before_send(self, monkeypatch): 401 """Bold+italic ***text*** survives tool-layer formatting.""" 402 _ensure_slack_mock(monkeypatch) 403 import gateway.platforms.slack as slack_mod 404 405 monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) 406 send = AsyncMock(return_value={"success": True, "message_id": "1"}) 407 with patch("tools.send_message_tool._send_slack", send): 408 result = asyncio.run( 409 _send_to_platform( 410 Platform.SLACK, 411 SimpleNamespace(enabled=True, token="***", extra={}), 412 "C123", 413 "***important*** update", 414 ) 415 ) 416 assert result["success"] is True 417 sent_text = send.await_args.args[2] 418 assert "*_important_*" in sent_text 419 420 def test_slack_blockquote_formatted_before_send(self, monkeypatch): 421 """Blockquote '>' markers must survive formatting (not escaped to '>').""" 422 _ensure_slack_mock(monkeypatch) 423 import gateway.platforms.slack as slack_mod 424 425 monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) 426 send = AsyncMock(return_value={"success": True, "message_id": "1"}) 427 with patch("tools.send_message_tool._send_slack", send): 428 result = asyncio.run( 429 _send_to_platform( 430 Platform.SLACK, 431 SimpleNamespace(enabled=True, token="***", extra={}), 432 "C123", 433 "> important quote\n\nnormal text & stuff", 434 ) 435 ) 436 assert result["success"] is True 437 sent_text = send.await_args.args[2] 438 assert sent_text.startswith("> important quote") 439 assert "&" in sent_text # & is escaped 440 assert ">" not in sent_text.split("\n")[0] # > in blockquote is NOT escaped 441 442 def test_slack_pre_escaped_entities_not_double_escaped(self, monkeypatch): 443 """Pre-escaped HTML entities survive tool-layer formatting without double-escaping.""" 444 _ensure_slack_mock(monkeypatch) 445 import gateway.platforms.slack as slack_mod 446 monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) 447 send = AsyncMock(return_value={"success": True, "message_id": "1"}) 448 with patch("tools.send_message_tool._send_slack", send): 449 result = asyncio.run( 450 _send_to_platform( 451 Platform.SLACK, 452 SimpleNamespace(enabled=True, token="***", extra={}), 453 "C123", 454 "AT&T <tag> test", 455 ) 456 ) 457 assert result["success"] is True 458 sent_text = send.await_args.args[2] 459 assert "&amp;" not in sent_text 460 assert "&lt;" not in sent_text 461 assert "AT&T" in sent_text 462 463 def test_slack_url_with_parens_formatted_before_send(self, monkeypatch): 464 """Wikipedia-style URL with parens survives tool-layer formatting.""" 465 _ensure_slack_mock(monkeypatch) 466 import gateway.platforms.slack as slack_mod 467 monkeypatch.setattr(slack_mod, "SLACK_AVAILABLE", True) 468 send = AsyncMock(return_value={"success": True, "message_id": "1"}) 469 with patch("tools.send_message_tool._send_slack", send): 470 result = asyncio.run( 471 _send_to_platform( 472 Platform.SLACK, 473 SimpleNamespace(enabled=True, token="***", extra={}), 474 "C123", 475 "See [Foo](https://en.wikipedia.org/wiki/Foo_(bar))", 476 ) 477 ) 478 assert result["success"] is True 479 sent_text = send.await_args.args[2] 480 assert "<https://en.wikipedia.org/wiki/Foo_(bar)|Foo>" in sent_text 481 482 def test_telegram_media_attaches_to_last_chunk(self): 483 484 sent_calls = [] 485 486 async def fake_send(token, chat_id, message, media_files=None, thread_id=None, disable_link_previews=False): 487 sent_calls.append(media_files or []) 488 return {"success": True, "platform": "telegram", "chat_id": chat_id, "message_id": str(len(sent_calls))} 489 490 long_msg = "word " * 2000 # ~10000 chars, well over 4096 491 media = [("/tmp/photo.png", False)] 492 with patch("tools.send_message_tool._send_telegram", fake_send): 493 asyncio.run( 494 _send_to_platform( 495 Platform.TELEGRAM, 496 SimpleNamespace(enabled=True, token="tok", extra={}), 497 "123", long_msg, media_files=media, 498 ) 499 ) 500 assert len(sent_calls) >= 3 501 assert all(call == [] for call in sent_calls[:-1]) 502 assert sent_calls[-1] == media 503 504 def test_matrix_media_uses_native_adapter_helper(self): 505 506 doc_path = Path("/tmp/test-send-message-matrix.pdf") 507 doc_path.write_bytes(b"%PDF-1.4 test") 508 509 try: 510 helper = AsyncMock(return_value={"success": True, "platform": "matrix", "chat_id": "!room:example.com", "message_id": "$evt"}) 511 with patch("tools.send_message_tool._send_matrix_via_adapter", helper): 512 result = asyncio.run( 513 _send_to_platform( 514 Platform.MATRIX, 515 SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), 516 "!room:example.com", 517 "here you go", 518 media_files=[(str(doc_path), False)], 519 ) 520 ) 521 522 assert result["success"] is True 523 helper.assert_awaited_once() 524 call = helper.await_args 525 assert call.args[1] == "!room:example.com" 526 assert call.args[2] == "here you go" 527 assert call.kwargs["media_files"] == [(str(doc_path), False)] 528 finally: 529 doc_path.unlink(missing_ok=True) 530 531 def test_matrix_text_only_uses_lightweight_path(self): 532 """Text-only Matrix sends should NOT go through the heavy adapter path.""" 533 helper = AsyncMock() 534 lightweight = AsyncMock(return_value={"success": True, "platform": "matrix", "chat_id": "!room:ex.com", "message_id": "$txt"}) 535 with patch("tools.send_message_tool._send_matrix_via_adapter", helper), \ 536 patch("tools.send_message_tool._send_matrix", lightweight): 537 result = asyncio.run( 538 _send_to_platform( 539 Platform.MATRIX, 540 SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), 541 "!room:ex.com", 542 "just text, no files", 543 ) 544 ) 545 546 assert result["success"] is True 547 helper.assert_not_awaited() 548 lightweight.assert_awaited_once() 549 550 def test_send_matrix_via_adapter_sends_document(self, tmp_path): 551 file_path = tmp_path / "report.pdf" 552 file_path.write_bytes(b"%PDF-1.4 test") 553 554 calls = [] 555 556 class FakeAdapter: 557 def __init__(self, _config): 558 self.connected = False 559 560 async def connect(self): 561 self.connected = True 562 calls.append(("connect",)) 563 return True 564 565 async def send(self, chat_id, message, metadata=None): 566 calls.append(("send", chat_id, message, metadata)) 567 return SimpleNamespace(success=True, message_id="$text") 568 569 async def send_document(self, chat_id, file_path, metadata=None): 570 calls.append(("send_document", chat_id, file_path, metadata)) 571 return SimpleNamespace(success=True, message_id="$file") 572 573 async def disconnect(self): 574 calls.append(("disconnect",)) 575 576 fake_module = SimpleNamespace(MatrixAdapter=FakeAdapter) 577 578 with patch.dict(sys.modules, {"gateway.platforms.matrix": fake_module}): 579 result = asyncio.run( 580 _send_matrix_via_adapter( 581 SimpleNamespace(enabled=True, token="tok", extra={"homeserver": "https://matrix.example.com"}), 582 "!room:example.com", 583 "report attached", 584 media_files=[(str(file_path), False)], 585 ) 586 ) 587 588 assert result == { 589 "success": True, 590 "platform": "matrix", 591 "chat_id": "!room:example.com", 592 "message_id": "$file", 593 } 594 assert calls == [ 595 ("connect",), 596 ("send", "!room:example.com", "report attached", None), 597 ("send_document", "!room:example.com", str(file_path), None), 598 ("disconnect",), 599 ] 600 601 602 # --------------------------------------------------------------------------- 603 # HTML auto-detection in Telegram send 604 # --------------------------------------------------------------------------- 605 606 607 class TestSendToPlatformWhatsapp: 608 def test_whatsapp_routes_via_local_bridge_sender(self): 609 chat_id = "test-user@lid" 610 async_mock = AsyncMock(return_value={"success": True, "platform": "whatsapp", "chat_id": chat_id, "message_id": "abc123"}) 611 612 with patch("tools.send_message_tool._send_whatsapp", async_mock): 613 result = asyncio.run( 614 _send_to_platform( 615 Platform.WHATSAPP, 616 SimpleNamespace(enabled=True, token=None, extra={"bridge_port": 3000}), 617 chat_id, 618 "hello from hermes", 619 ) 620 ) 621 622 assert result["success"] is True 623 async_mock.assert_awaited_once_with({"bridge_port": 3000}, chat_id, "hello from hermes") 624 625 626 class TestSendTelegramHtmlDetection: 627 """Verify that messages containing HTML tags are sent with parse_mode=HTML 628 and that plain / markdown messages use MarkdownV2.""" 629 630 def _make_bot(self): 631 bot = MagicMock() 632 bot.send_message = AsyncMock(return_value=SimpleNamespace(message_id=1)) 633 bot.send_photo = AsyncMock() 634 bot.send_video = AsyncMock() 635 bot.send_voice = AsyncMock() 636 bot.send_audio = AsyncMock() 637 bot.send_document = AsyncMock() 638 return bot 639 640 def test_html_message_uses_html_parse_mode(self, monkeypatch): 641 bot = self._make_bot() 642 _install_telegram_mock(monkeypatch, bot) 643 644 asyncio.run( 645 _send_telegram("tok", "123", "<b>Hello</b> world") 646 ) 647 648 bot.send_message.assert_awaited_once() 649 kwargs = bot.send_message.await_args.kwargs 650 assert kwargs["parse_mode"] == "HTML" 651 assert kwargs["text"] == "<b>Hello</b> world" 652 653 def test_plain_text_uses_markdown_v2(self, monkeypatch): 654 bot = self._make_bot() 655 _install_telegram_mock(monkeypatch, bot) 656 657 asyncio.run( 658 _send_telegram("tok", "123", "Just plain text, no tags") 659 ) 660 661 bot.send_message.assert_awaited_once() 662 kwargs = bot.send_message.await_args.kwargs 663 assert kwargs["parse_mode"] == "MarkdownV2" 664 665 def test_disable_link_previews_sets_disable_web_page_preview(self, monkeypatch): 666 bot = self._make_bot() 667 _install_telegram_mock(monkeypatch, bot) 668 669 asyncio.run( 670 _send_telegram("tok", "123", "https://example.com", disable_link_previews=True) 671 ) 672 673 kwargs = bot.send_message.await_args.kwargs 674 assert kwargs["disable_web_page_preview"] is True 675 676 def test_html_with_code_and_pre_tags(self, monkeypatch): 677 bot = self._make_bot() 678 _install_telegram_mock(monkeypatch, bot) 679 680 html = "<pre>code block</pre> and <code>inline</code>" 681 asyncio.run(_send_telegram("tok", "123", html)) 682 683 kwargs = bot.send_message.await_args.kwargs 684 assert kwargs["parse_mode"] == "HTML" 685 686 def test_closing_tag_detected(self, monkeypatch): 687 bot = self._make_bot() 688 _install_telegram_mock(monkeypatch, bot) 689 690 asyncio.run(_send_telegram("tok", "123", "text </div> more")) 691 692 kwargs = bot.send_message.await_args.kwargs 693 assert kwargs["parse_mode"] == "HTML" 694 695 def test_angle_brackets_in_math_not_detected(self, monkeypatch): 696 """Expressions like 'x < 5' or '3 > 2' should not trigger HTML mode.""" 697 bot = self._make_bot() 698 _install_telegram_mock(monkeypatch, bot) 699 700 asyncio.run(_send_telegram("tok", "123", "if x < 5 then y > 2")) 701 702 kwargs = bot.send_message.await_args.kwargs 703 assert kwargs["parse_mode"] == "MarkdownV2" 704 705 def test_html_parse_failure_falls_back_to_plain(self, monkeypatch): 706 """If Telegram rejects the HTML, fall back to plain text.""" 707 bot = self._make_bot() 708 bot.send_message = AsyncMock( 709 side_effect=[ 710 Exception("Bad Request: can't parse entities: unsupported html tag"), 711 SimpleNamespace(message_id=2), # plain fallback succeeds 712 ] 713 ) 714 _install_telegram_mock(monkeypatch, bot) 715 716 result = asyncio.run( 717 _send_telegram("tok", "123", "<invalid>broken html</invalid>") 718 ) 719 720 assert result["success"] is True 721 assert bot.send_message.await_count == 2 722 second_call = bot.send_message.await_args_list[1].kwargs 723 assert second_call["parse_mode"] is None 724 725 def test_transient_bad_gateway_retries_text_send(self, monkeypatch): 726 bot = self._make_bot() 727 bot.send_message = AsyncMock( 728 side_effect=[ 729 Exception("502 Bad Gateway"), 730 SimpleNamespace(message_id=2), 731 ] 732 ) 733 _install_telegram_mock(monkeypatch, bot) 734 735 with patch("asyncio.sleep", new=AsyncMock()) as sleep_mock: 736 result = asyncio.run(_send_telegram("tok", "123", "hello")) 737 738 assert result["success"] is True 739 assert bot.send_message.await_count == 2 740 sleep_mock.assert_awaited_once() 741 742 743 # --------------------------------------------------------------------------- 744 # Tests for Discord thread_id support 745 # --------------------------------------------------------------------------- 746 747 748 class TestParseTargetRefDiscord: 749 """_parse_target_ref correctly extracts chat_id and thread_id for Discord.""" 750 751 def test_discord_chat_id_with_thread_id(self): 752 """discord:chat_id:thread_id returns both values.""" 753 chat_id, thread_id, is_explicit = _parse_target_ref("discord", "-1001234567890:17585") 754 assert chat_id == "-1001234567890" 755 assert thread_id == "17585" 756 assert is_explicit is True 757 758 def test_discord_chat_id_without_thread_id(self): 759 """discord:chat_id returns None for thread_id.""" 760 chat_id, thread_id, is_explicit = _parse_target_ref("discord", "9876543210") 761 assert chat_id == "9876543210" 762 assert thread_id is None 763 assert is_explicit is True 764 765 def test_discord_large_snowflake_without_thread(self): 766 """Large Discord snowflake IDs work without thread.""" 767 chat_id, thread_id, is_explicit = _parse_target_ref("discord", "1003724596514") 768 assert chat_id == "1003724596514" 769 assert thread_id is None 770 assert is_explicit is True 771 772 def test_discord_channel_with_thread(self): 773 """Full Discord format: channel:thread.""" 774 chat_id, thread_id, is_explicit = _parse_target_ref("discord", "1003724596514:99999") 775 assert chat_id == "1003724596514" 776 assert thread_id == "99999" 777 assert is_explicit is True 778 779 def test_discord_whitespace_is_stripped(self): 780 """Whitespace around Discord targets is stripped.""" 781 chat_id, thread_id, is_explicit = _parse_target_ref("discord", " 123456:789 ") 782 assert chat_id == "123456" 783 assert thread_id == "789" 784 assert is_explicit is True 785 786 787 class TestParseTargetRefMatrix: 788 """_parse_target_ref correctly handles Matrix room IDs and user MXIDs.""" 789 790 def test_matrix_room_id_is_explicit(self): 791 """Matrix room IDs (!) are recognized as explicit targets.""" 792 chat_id, thread_id, is_explicit = _parse_target_ref("matrix", "!HLOQwxYGgFPMPJUSNR:matrix.org") 793 assert chat_id == "!HLOQwxYGgFPMPJUSNR:matrix.org" 794 assert thread_id is None 795 assert is_explicit is True 796 797 def test_matrix_user_mxid_is_explicit(self): 798 """Matrix user MXIDs (@) are recognized as explicit targets.""" 799 chat_id, thread_id, is_explicit = _parse_target_ref("matrix", "@hermes:matrix.org") 800 assert chat_id == "@hermes:matrix.org" 801 assert thread_id is None 802 assert is_explicit is True 803 804 def test_matrix_alias_is_not_explicit(self): 805 """Matrix room aliases (#) are NOT explicit — they need resolution.""" 806 chat_id, thread_id, is_explicit = _parse_target_ref("matrix", "#general:matrix.org") 807 assert chat_id is None 808 assert is_explicit is False 809 810 def test_matrix_prefix_only_matches_matrix_platform(self): 811 """! and @ prefixes are only treated as explicit for the matrix platform.""" 812 chat_id, _, is_explicit = _parse_target_ref("telegram", "!something") 813 assert is_explicit is False 814 815 chat_id, _, is_explicit = _parse_target_ref("discord", "@someone") 816 assert is_explicit is False 817 818 819 class TestParseTargetRefE164: 820 """_parse_target_ref accepts E.164 phone numbers for phone-based platforms.""" 821 822 def test_signal_e164_preserves_plus_prefix(self): 823 """signal:+E164 is explicit and preserves the leading '+' for signal-cli.""" 824 chat_id, thread_id, is_explicit = _parse_target_ref("signal", "+41791234567") 825 assert chat_id == "+41791234567" 826 assert thread_id is None 827 assert is_explicit is True 828 829 def test_sms_e164_is_explicit(self): 830 chat_id, _, is_explicit = _parse_target_ref("sms", "+15551234567") 831 assert chat_id == "+15551234567" 832 assert is_explicit is True 833 834 def test_whatsapp_e164_is_explicit(self): 835 chat_id, _, is_explicit = _parse_target_ref("whatsapp", "+15551234567") 836 assert chat_id == "+15551234567" 837 assert is_explicit is True 838 839 def test_signal_bare_digits_still_work(self): 840 """Bare digit strings continue to match the generic numeric branch.""" 841 chat_id, _, is_explicit = _parse_target_ref("signal", "15551234567") 842 assert chat_id == "15551234567" 843 assert is_explicit is True 844 845 def test_signal_invalid_e164_rejected(self): 846 """Too-short, too-long, and non-numeric E.164 strings are not explicit.""" 847 assert _parse_target_ref("signal", "+123")[2] is False 848 assert _parse_target_ref("signal", "+1234567890123456")[2] is False 849 assert _parse_target_ref("signal", "+12abc4567890")[2] is False 850 assert _parse_target_ref("signal", "+")[2] is False 851 852 def test_e164_prefix_only_matches_phone_platforms(self): 853 """'+' prefix must NOT be treated as explicit for non-phone platforms.""" 854 assert _parse_target_ref("telegram", "+15551234567")[2] is False 855 assert _parse_target_ref("discord", "+15551234567")[2] is False 856 assert _parse_target_ref("matrix", "+15551234567")[2] is False 857 858 859 class TestParseTargetRefSlack: 860 """_parse_target_ref recognizes Slack channel/user IDs as explicit.""" 861 862 def test_public_channel_id_is_explicit(self): 863 chat_id, thread_id, is_explicit = _parse_target_ref("slack", "C0B0QV5434G") 864 assert chat_id == "C0B0QV5434G" 865 assert thread_id is None 866 assert is_explicit is True 867 868 def test_private_channel_id_is_explicit(self): 869 assert _parse_target_ref("slack", "G123ABCDEF")[2] is True 870 871 def test_dm_id_is_explicit(self): 872 assert _parse_target_ref("slack", "D123ABCDEF")[2] is True 873 874 def test_user_id_is_not_explicit(self): 875 """Slack user IDs (U...) and workspace IDs (W...) are NOT explicit send 876 targets. chat.postMessage rejects them — a DM must be opened first via 877 conversations.open to obtain a D... conversation ID. 878 """ 879 assert _parse_target_ref("slack", "U123ABCDEF")[2] is False 880 assert _parse_target_ref("slack", "W123ABCDEF")[2] is False 881 882 def test_whitespace_is_stripped(self): 883 chat_id, _, is_explicit = _parse_target_ref("slack", " C0B0QV5434G ") 884 assert chat_id == "C0B0QV5434G" 885 assert is_explicit is True 886 887 def test_lowercase_or_short_id_is_not_explicit(self): 888 assert _parse_target_ref("slack", "c0b0qv5434g")[2] is False 889 assert _parse_target_ref("slack", "C123")[2] is False 890 assert _parse_target_ref("slack", "X0B0QV5434G")[2] is False 891 892 def test_slack_id_not_explicit_for_other_platforms(self): 893 assert _parse_target_ref("discord", "C0B0QV5434G")[2] is False 894 assert _parse_target_ref("telegram", "C0B0QV5434G")[2] is False 895 896 897 class TestSendDiscordThreadId: 898 """_send_discord uses thread_id when provided.""" 899 900 @staticmethod 901 def _build_mock(response_status, response_data=None, response_text="error body"): 902 """Build a properly-structured aiohttp mock chain. 903 904 session.post() returns a context manager yielding mock_resp. 905 """ 906 mock_resp = MagicMock() 907 mock_resp.status = response_status 908 mock_resp.json = AsyncMock(return_value=response_data or {"id": "msg123"}) 909 mock_resp.text = AsyncMock(return_value=response_text) 910 911 # mock_resp as async context manager (for "async with session.post(...) as resp") 912 mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) 913 mock_resp.__aexit__ = AsyncMock(return_value=None) 914 915 mock_session = MagicMock() 916 mock_session.__aenter__ = AsyncMock(return_value=mock_session) 917 mock_session.__aexit__ = AsyncMock(return_value=None) 918 mock_session.post = MagicMock(return_value=mock_resp) 919 920 return mock_session, mock_resp 921 922 def _run(self, token, chat_id, message, thread_id=None): 923 return asyncio.run(_send_discord(token, chat_id, message, thread_id=thread_id)) 924 925 def test_without_thread_id_uses_chat_id_endpoint(self): 926 """When no thread_id, sends to /channels/{chat_id}/messages.""" 927 mock_session, _ = self._build_mock(200) 928 with patch("aiohttp.ClientSession", return_value=mock_session): 929 self._run("tok", "111222333", "hello world") 930 call_url = mock_session.post.call_args.args[0] 931 assert call_url == "https://discord.com/api/v10/channels/111222333/messages" 932 933 def test_with_thread_id_uses_thread_endpoint(self): 934 """When thread_id is provided, sends to /channels/{thread_id}/messages.""" 935 mock_session, _ = self._build_mock(200) 936 with patch("aiohttp.ClientSession", return_value=mock_session): 937 self._run("tok", "999888777", "hello from thread", thread_id="555444333") 938 call_url = mock_session.post.call_args.args[0] 939 assert call_url == "https://discord.com/api/v10/channels/555444333/messages" 940 941 def test_success_returns_message_id(self): 942 """Successful send returns the Discord message ID.""" 943 mock_session, _ = self._build_mock(200, response_data={"id": "9876543210"}) 944 with patch("aiohttp.ClientSession", return_value=mock_session): 945 result = self._run("tok", "111", "hi", thread_id="999") 946 assert result["success"] is True 947 assert result["message_id"] == "9876543210" 948 assert result["chat_id"] == "111" 949 950 def test_error_status_returns_error_dict(self): 951 """Non-200/201 responses return an error dict.""" 952 mock_session, _ = self._build_mock(403, response_data={"message": "Forbidden"}) 953 with patch("aiohttp.ClientSession", return_value=mock_session): 954 result = self._run("tok", "111", "hi") 955 assert "error" in result 956 assert "403" in result["error"] 957 958 959 class TestSendToPlatformDiscordThread: 960 """_send_to_platform passes thread_id through to _send_discord.""" 961 962 def test_discord_thread_id_passed_to_send_discord(self): 963 """Discord platform with thread_id passes it to _send_discord.""" 964 send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 965 966 with patch("tools.send_message_tool._send_discord", send_mock): 967 result = asyncio.run( 968 _send_to_platform( 969 Platform.DISCORD, 970 SimpleNamespace(enabled=True, token="tok", extra={}), 971 "-1001234567890", 972 "hello thread", 973 thread_id="17585", 974 ) 975 ) 976 977 assert result["success"] is True 978 send_mock.assert_awaited_once() 979 _, call_kwargs = send_mock.await_args 980 assert call_kwargs["thread_id"] == "17585" 981 982 def test_discord_no_thread_id_when_not_provided(self): 983 """Discord platform without thread_id passes None.""" 984 send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 985 986 with patch("tools.send_message_tool._send_discord", send_mock): 987 result = asyncio.run( 988 _send_to_platform( 989 Platform.DISCORD, 990 SimpleNamespace(enabled=True, token="tok", extra={}), 991 "9876543210", 992 "hello channel", 993 ) 994 ) 995 996 send_mock.assert_awaited_once() 997 _, call_kwargs = send_mock.await_args 998 assert call_kwargs["thread_id"] is None 999 1000 1001 # --------------------------------------------------------------------------- 1002 # Discord media attachment support 1003 # --------------------------------------------------------------------------- 1004 1005 1006 class TestSendDiscordMedia: 1007 """_send_discord uploads media files via multipart/form-data.""" 1008 1009 @staticmethod 1010 def _build_mock(response_status, response_data=None, response_text="error body"): 1011 """Build a properly-structured aiohttp mock chain.""" 1012 mock_resp = MagicMock() 1013 mock_resp.status = response_status 1014 mock_resp.json = AsyncMock(return_value=response_data or {"id": "msg123"}) 1015 mock_resp.text = AsyncMock(return_value=response_text) 1016 mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) 1017 mock_resp.__aexit__ = AsyncMock(return_value=None) 1018 1019 mock_session = MagicMock() 1020 mock_session.__aenter__ = AsyncMock(return_value=mock_session) 1021 mock_session.__aexit__ = AsyncMock(return_value=None) 1022 mock_session.post = MagicMock(return_value=mock_resp) 1023 1024 return mock_session, mock_resp 1025 1026 def test_text_and_media_sends_both(self, tmp_path): 1027 """Text message is sent first, then each media file as multipart.""" 1028 img = tmp_path / "photo.png" 1029 img.write_bytes(b"\x89PNG fake image data") 1030 1031 mock_session, _ = self._build_mock(200, {"id": "msg999"}) 1032 with patch("aiohttp.ClientSession", return_value=mock_session): 1033 result = asyncio.run( 1034 _send_discord("tok", "111", "hello", media_files=[(str(img), False)]) 1035 ) 1036 1037 assert result["success"] is True 1038 assert result["message_id"] == "msg999" 1039 # Two POSTs: one text JSON, one multipart upload 1040 assert mock_session.post.call_count == 2 1041 1042 def test_media_only_skips_text_post(self, tmp_path): 1043 """When message is empty and media is present, text POST is skipped.""" 1044 img = tmp_path / "photo.png" 1045 img.write_bytes(b"\x89PNG fake image data") 1046 1047 mock_session, _ = self._build_mock(200, {"id": "media_only"}) 1048 with patch("aiohttp.ClientSession", return_value=mock_session): 1049 result = asyncio.run( 1050 _send_discord("tok", "222", " ", media_files=[(str(img), False)]) 1051 ) 1052 1053 assert result["success"] is True 1054 # Only one POST: the media upload (text was whitespace-only) 1055 assert mock_session.post.call_count == 1 1056 1057 def test_missing_media_file_collected_as_warning(self): 1058 """Non-existent media paths produce warnings but don't fail.""" 1059 mock_session, _ = self._build_mock(200, {"id": "txt_ok"}) 1060 with patch("aiohttp.ClientSession", return_value=mock_session): 1061 result = asyncio.run( 1062 _send_discord("tok", "333", "hello", media_files=[("/nonexistent/file.png", False)]) 1063 ) 1064 1065 assert result["success"] is True 1066 assert "warnings" in result 1067 assert any("not found" in w for w in result["warnings"]) 1068 # Only the text POST was made, media was skipped 1069 assert mock_session.post.call_count == 1 1070 1071 def test_media_upload_failure_collected_as_warning(self, tmp_path): 1072 """Failed media upload becomes a warning, text still succeeds.""" 1073 img = tmp_path / "photo.png" 1074 img.write_bytes(b"\x89PNG fake image data") 1075 1076 # First call (text) succeeds, second call (media) returns 413 1077 text_resp = MagicMock() 1078 text_resp.status = 200 1079 text_resp.json = AsyncMock(return_value={"id": "txt_ok"}) 1080 text_resp.__aenter__ = AsyncMock(return_value=text_resp) 1081 text_resp.__aexit__ = AsyncMock(return_value=None) 1082 1083 media_resp = MagicMock() 1084 media_resp.status = 413 1085 media_resp.text = AsyncMock(return_value="Request Entity Too Large") 1086 media_resp.__aenter__ = AsyncMock(return_value=media_resp) 1087 media_resp.__aexit__ = AsyncMock(return_value=None) 1088 1089 mock_session = MagicMock() 1090 mock_session.__aenter__ = AsyncMock(return_value=mock_session) 1091 mock_session.__aexit__ = AsyncMock(return_value=None) 1092 mock_session.post = MagicMock(side_effect=[text_resp, media_resp]) 1093 1094 with patch("aiohttp.ClientSession", return_value=mock_session): 1095 result = asyncio.run( 1096 _send_discord("tok", "444", "hello", media_files=[(str(img), False)]) 1097 ) 1098 1099 assert result["success"] is True 1100 assert result["message_id"] == "txt_ok" 1101 assert "warnings" in result 1102 assert any("413" in w for w in result["warnings"]) 1103 1104 def test_no_text_no_media_returns_error(self): 1105 """Empty text with no media returns error dict.""" 1106 mock_session, _ = self._build_mock(200) 1107 with patch("aiohttp.ClientSession", return_value=mock_session): 1108 result = asyncio.run( 1109 _send_discord("tok", "555", "", media_files=[]) 1110 ) 1111 1112 # Text is empty but media_files is empty, so text POST fires 1113 # (the "skip text if media present" condition isn't met) 1114 assert result["success"] is True 1115 1116 def test_multiple_media_files_uploaded_separately(self, tmp_path): 1117 """Each media file gets its own multipart POST.""" 1118 img1 = tmp_path / "a.png" 1119 img1.write_bytes(b"img1") 1120 img2 = tmp_path / "b.jpg" 1121 img2.write_bytes(b"img2") 1122 1123 mock_session, _ = self._build_mock(200, {"id": "last"}) 1124 with patch("aiohttp.ClientSession", return_value=mock_session): 1125 result = asyncio.run( 1126 _send_discord("tok", "666", "hi", media_files=[ 1127 (str(img1), False), (str(img2), False) 1128 ]) 1129 ) 1130 1131 assert result["success"] is True 1132 # 1 text POST + 2 media POSTs = 3 1133 assert mock_session.post.call_count == 3 1134 1135 1136 class TestSendToPlatformDiscordMedia: 1137 """_send_to_platform routes Discord media correctly.""" 1138 1139 def test_media_files_passed_on_last_chunk_only(self): 1140 """Discord media_files are only passed on the final chunk.""" 1141 call_log = [] 1142 1143 async def mock_send_discord(token, chat_id, message, thread_id=None, media_files=None): 1144 call_log.append({"message": message, "media_files": media_files or []}) 1145 return {"success": True, "platform": "discord", "chat_id": chat_id, "message_id": "1"} 1146 1147 # A message long enough to get chunked (Discord limit is 2000) 1148 long_msg = "A" * 1900 + " " + "B" * 1900 1149 1150 with patch("tools.send_message_tool._send_discord", side_effect=mock_send_discord): 1151 result = asyncio.run( 1152 _send_to_platform( 1153 Platform.DISCORD, 1154 SimpleNamespace(enabled=True, token="tok", extra={}), 1155 "999", 1156 long_msg, 1157 media_files=[("/fake/img.png", False)], 1158 ) 1159 ) 1160 1161 assert result["success"] is True 1162 assert len(call_log) == 2 # Message was chunked 1163 assert call_log[0]["media_files"] == [] # First chunk: no media 1164 assert call_log[1]["media_files"] == [("/fake/img.png", False)] # Last chunk: media attached 1165 1166 def test_single_chunk_gets_media(self): 1167 """Short message (single chunk) gets media_files directly.""" 1168 send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 1169 1170 with patch("tools.send_message_tool._send_discord", send_mock): 1171 result = asyncio.run( 1172 _send_to_platform( 1173 Platform.DISCORD, 1174 SimpleNamespace(enabled=True, token="tok", extra={}), 1175 "888", 1176 "short message", 1177 media_files=[("/fake/img.png", False)], 1178 ) 1179 ) 1180 1181 assert result["success"] is True 1182 send_mock.assert_awaited_once() 1183 call_kwargs = send_mock.await_args.kwargs 1184 assert call_kwargs["media_files"] == [("/fake/img.png", False)] 1185 1186 1187 class TestSendMatrixUrlEncoding: 1188 """_send_matrix URL-encodes Matrix room IDs in the API path.""" 1189 1190 def test_room_id_is_percent_encoded_in_url(self): 1191 """Matrix room IDs with ! and : are percent-encoded in the PUT URL.""" 1192 import aiohttp 1193 1194 mock_resp = MagicMock() 1195 mock_resp.status = 200 1196 mock_resp.json = AsyncMock(return_value={"event_id": "$evt123"}) 1197 mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) 1198 mock_resp.__aexit__ = AsyncMock(return_value=None) 1199 1200 mock_session = MagicMock() 1201 mock_session.put = MagicMock(return_value=mock_resp) 1202 mock_session.__aenter__ = AsyncMock(return_value=mock_session) 1203 mock_session.__aexit__ = AsyncMock(return_value=None) 1204 1205 with patch("aiohttp.ClientSession", return_value=mock_session): 1206 from tools.send_message_tool import _send_matrix 1207 result = asyncio.get_event_loop().run_until_complete( 1208 _send_matrix( 1209 "test_token", 1210 {"homeserver": "https://matrix.example.org"}, 1211 "!HLOQwxYGgFPMPJUSNR:matrix.org", 1212 "hello", 1213 ) 1214 ) 1215 1216 assert result["success"] is True 1217 # Verify the URL was called with percent-encoded room ID 1218 put_url = mock_session.put.call_args[0][0] 1219 assert "%21HLOQwxYGgFPMPJUSNR%3Amatrix.org" in put_url 1220 assert "!HLOQwxYGgFPMPJUSNR:matrix.org" not in put_url 1221 1222 1223 # --------------------------------------------------------------------------- 1224 # Tests for _derive_forum_thread_name 1225 # --------------------------------------------------------------------------- 1226 1227 1228 class TestDeriveForumThreadName: 1229 def test_single_line_message(self): 1230 assert _derive_forum_thread_name("Hello world") == "Hello world" 1231 1232 def test_multi_line_uses_first_line(self): 1233 assert _derive_forum_thread_name("First line\nSecond line") == "First line" 1234 1235 def test_strips_markdown_heading(self): 1236 assert _derive_forum_thread_name("## My Heading") == "My Heading" 1237 1238 def test_strips_multiple_hash_levels(self): 1239 assert _derive_forum_thread_name("### Deep heading") == "Deep heading" 1240 1241 def test_empty_message_falls_back_to_default(self): 1242 assert _derive_forum_thread_name("") == "New Post" 1243 1244 def test_whitespace_only_falls_back(self): 1245 assert _derive_forum_thread_name(" \n ") == "New Post" 1246 1247 def test_hash_only_falls_back(self): 1248 assert _derive_forum_thread_name("###") == "New Post" 1249 1250 def test_truncates_to_100_chars(self): 1251 long_title = "A" * 200 1252 result = _derive_forum_thread_name(long_title) 1253 assert len(result) == 100 1254 1255 def test_strips_whitespace_around_first_line(self): 1256 assert _derive_forum_thread_name(" Title \nBody") == "Title" 1257 1258 1259 # --------------------------------------------------------------------------- 1260 # Tests for _send_discord with forum channel support 1261 # --------------------------------------------------------------------------- 1262 1263 1264 class TestSendDiscordForum: 1265 """_send_discord creates thread posts for forum channels.""" 1266 1267 @staticmethod 1268 def _build_mock(response_status, response_data=None, response_text="error body"): 1269 mock_resp = MagicMock() 1270 mock_resp.status = response_status 1271 mock_resp.json = AsyncMock(return_value=response_data or {}) 1272 mock_resp.text = AsyncMock(return_value=response_text) 1273 mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) 1274 mock_resp.__aexit__ = AsyncMock(return_value=None) 1275 1276 mock_session = MagicMock() 1277 mock_session.__aenter__ = AsyncMock(return_value=mock_session) 1278 mock_session.__aexit__ = AsyncMock(return_value=None) 1279 mock_session.post = MagicMock(return_value=mock_resp) 1280 mock_session.get = MagicMock(return_value=mock_resp) 1281 1282 return mock_session, mock_resp 1283 1284 def test_directory_forum_creates_thread(self): 1285 """Directory says 'forum' — creates a thread post.""" 1286 thread_data = { 1287 "id": "t123", 1288 "message": {"id": "m456"}, 1289 } 1290 mock_session, _ = self._build_mock(200, response_data=thread_data) 1291 1292 with patch("aiohttp.ClientSession", return_value=mock_session), \ 1293 patch("gateway.channel_directory.lookup_channel_type", return_value="forum"): 1294 result = asyncio.run( 1295 _send_discord("tok", "forum_ch", "Hello forum") 1296 ) 1297 1298 assert result["success"] is True 1299 assert result["thread_id"] == "t123" 1300 assert result["message_id"] == "m456" 1301 # Should POST to threads endpoint, not messages 1302 call_url = mock_session.post.call_args.args[0] 1303 assert "/threads" in call_url 1304 assert "/messages" not in call_url 1305 1306 def test_directory_forum_skips_probe(self): 1307 """When directory says 'forum', no GET probe is made.""" 1308 thread_data = {"id": "t123", "message": {"id": "m456"}} 1309 mock_session, _ = self._build_mock(200, response_data=thread_data) 1310 1311 with patch("aiohttp.ClientSession", return_value=mock_session), \ 1312 patch("gateway.channel_directory.lookup_channel_type", return_value="forum"): 1313 asyncio.run( 1314 _send_discord("tok", "forum_ch", "Hello") 1315 ) 1316 1317 # get() should never be called — directory resolved the type 1318 mock_session.get.assert_not_called() 1319 1320 def test_directory_channel_skips_forum(self): 1321 """When directory says 'channel', sends via normal messages endpoint.""" 1322 mock_session, _ = self._build_mock(200, response_data={"id": "msg1"}) 1323 1324 with patch("aiohttp.ClientSession", return_value=mock_session), \ 1325 patch("gateway.channel_directory.lookup_channel_type", return_value="channel"): 1326 result = asyncio.run( 1327 _send_discord("tok", "ch1", "Hello") 1328 ) 1329 1330 assert result["success"] is True 1331 call_url = mock_session.post.call_args.args[0] 1332 assert "/messages" in call_url 1333 assert "/threads" not in call_url 1334 1335 def test_directory_none_probes_and_detects_forum(self): 1336 """When directory has no entry, probes GET /channels/{id} and detects type 15.""" 1337 probe_resp = MagicMock() 1338 probe_resp.status = 200 1339 probe_resp.json = AsyncMock(return_value={"type": 15}) 1340 probe_resp.__aenter__ = AsyncMock(return_value=probe_resp) 1341 probe_resp.__aexit__ = AsyncMock(return_value=None) 1342 1343 thread_data = {"id": "t999", "message": {"id": "m888"}} 1344 thread_resp = MagicMock() 1345 thread_resp.status = 200 1346 thread_resp.json = AsyncMock(return_value=thread_data) 1347 thread_resp.text = AsyncMock(return_value="") 1348 thread_resp.__aenter__ = AsyncMock(return_value=thread_resp) 1349 thread_resp.__aexit__ = AsyncMock(return_value=None) 1350 1351 probe_session = MagicMock() 1352 probe_session.__aenter__ = AsyncMock(return_value=probe_session) 1353 probe_session.__aexit__ = AsyncMock(return_value=None) 1354 probe_session.get = MagicMock(return_value=probe_resp) 1355 1356 thread_session = MagicMock() 1357 thread_session.__aenter__ = AsyncMock(return_value=thread_session) 1358 thread_session.__aexit__ = AsyncMock(return_value=None) 1359 thread_session.post = MagicMock(return_value=thread_resp) 1360 1361 session_iter = iter([probe_session, thread_session]) 1362 1363 with patch("aiohttp.ClientSession", side_effect=lambda **kw: next(session_iter)), \ 1364 patch("gateway.channel_directory.lookup_channel_type", return_value=None): 1365 result = asyncio.run( 1366 _send_discord("tok", "forum_ch", "Hello probe") 1367 ) 1368 1369 assert result["success"] is True 1370 assert result["thread_id"] == "t999" 1371 1372 def test_directory_lookup_exception_falls_through_to_probe(self): 1373 """When lookup_channel_type raises, falls through to API probe.""" 1374 mock_session, _ = self._build_mock(200, response_data={"id": "msg1"}) 1375 1376 with patch("aiohttp.ClientSession", return_value=mock_session), \ 1377 patch("gateway.channel_directory.lookup_channel_type", side_effect=Exception("io error")): 1378 result = asyncio.run( 1379 _send_discord("tok", "ch1", "Hello") 1380 ) 1381 1382 assert result["success"] is True 1383 # Falls through to probe (GET) 1384 mock_session.get.assert_called_once() 1385 1386 def test_forum_thread_creation_error(self): 1387 """Forum thread creation returning non-200/201 returns an error dict.""" 1388 mock_session, _ = self._build_mock(403, response_text="Forbidden") 1389 1390 with patch("aiohttp.ClientSession", return_value=mock_session), \ 1391 patch("gateway.channel_directory.lookup_channel_type", return_value="forum"): 1392 result = asyncio.run( 1393 _send_discord("tok", "forum_ch", "Hello") 1394 ) 1395 1396 assert "error" in result 1397 assert "403" in result["error"] 1398 1399 1400 1401 class TestSendToPlatformDiscordForum: 1402 """_send_to_platform delegates forum detection to _send_discord.""" 1403 1404 def test_send_to_platform_discord_delegates_to_send_discord(self): 1405 """Discord messages are routed through _send_discord, which handles forum detection.""" 1406 send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 1407 1408 with patch("tools.send_message_tool._send_discord", send_mock): 1409 result = asyncio.run( 1410 _send_to_platform( 1411 Platform.DISCORD, 1412 SimpleNamespace(enabled=True, token="tok", extra={}), 1413 "forum_ch", 1414 "Hello forum", 1415 ) 1416 ) 1417 1418 assert result["success"] is True 1419 send_mock.assert_awaited_once_with( 1420 "tok", "forum_ch", "Hello forum", media_files=[], thread_id=None, 1421 ) 1422 1423 def test_send_to_platform_discord_with_thread_id(self): 1424 """Thread ID is still passed through when sending to Discord.""" 1425 send_mock = AsyncMock(return_value={"success": True, "message_id": "1"}) 1426 1427 with patch("tools.send_message_tool._send_discord", send_mock): 1428 result = asyncio.run( 1429 _send_to_platform( 1430 Platform.DISCORD, 1431 SimpleNamespace(enabled=True, token="tok", extra={}), 1432 "ch1", 1433 "Hello thread", 1434 thread_id="17585", 1435 ) 1436 ) 1437 1438 assert result["success"] is True 1439 _, call_kwargs = send_mock.await_args 1440 assert call_kwargs["thread_id"] == "17585" 1441 1442 1443 # --------------------------------------------------------------------------- 1444 # Tests for _send_discord forum + media multipart upload 1445 # --------------------------------------------------------------------------- 1446 1447 1448 class TestSendDiscordForumMedia: 1449 """_send_discord uploads media as part of the starter message when the target is a forum.""" 1450 1451 @staticmethod 1452 def _build_thread_resp(thread_id="th_999", msg_id="msg_500"): 1453 resp = MagicMock() 1454 resp.status = 201 1455 resp.json = AsyncMock(return_value={"id": thread_id, "message": {"id": msg_id}}) 1456 resp.text = AsyncMock(return_value="") 1457 resp.__aenter__ = AsyncMock(return_value=resp) 1458 resp.__aexit__ = AsyncMock(return_value=None) 1459 return resp 1460 1461 def test_forum_with_media_uses_multipart(self, tmp_path, monkeypatch): 1462 """Forum + media → single multipart POST to /threads carrying the starter + files.""" 1463 from tools import send_message_tool as smt 1464 1465 img = tmp_path / "photo.png" 1466 img.write_bytes(b"\x89PNGbytes") 1467 1468 monkeypatch.setattr(smt, "lookup_channel_type", lambda p, cid: "forum", raising=False) 1469 monkeypatch.setattr( 1470 "gateway.channel_directory.lookup_channel_type", lambda p, cid: "forum" 1471 ) 1472 1473 thread_resp = self._build_thread_resp() 1474 session = MagicMock() 1475 session.__aenter__ = AsyncMock(return_value=session) 1476 session.__aexit__ = AsyncMock(return_value=None) 1477 session.post = MagicMock(return_value=thread_resp) 1478 1479 post_calls = [] 1480 orig_post = session.post 1481 1482 def track_post(url, **kwargs): 1483 post_calls.append({"url": url, "kwargs": kwargs}) 1484 return thread_resp 1485 1486 session.post = MagicMock(side_effect=track_post) 1487 1488 with patch("aiohttp.ClientSession", return_value=session): 1489 result = asyncio.run( 1490 _send_discord("tok", "forum_ch", "Thread title\nbody", media_files=[(str(img), False)]) 1491 ) 1492 1493 assert result["success"] is True 1494 assert result["thread_id"] == "th_999" 1495 assert result["message_id"] == "msg_500" 1496 # Exactly one POST — the combined thread-creation + attachments call 1497 assert len(post_calls) == 1 1498 assert post_calls[0]["url"].endswith("/threads") 1499 # Multipart form, not JSON 1500 assert post_calls[0]["kwargs"].get("data") is not None 1501 assert post_calls[0]["kwargs"].get("json") is None 1502 1503 def test_forum_without_media_still_json_only(self, tmp_path, monkeypatch): 1504 """Forum + no media → JSON POST (no multipart overhead).""" 1505 monkeypatch.setattr( 1506 "gateway.channel_directory.lookup_channel_type", lambda p, cid: "forum" 1507 ) 1508 1509 thread_resp = self._build_thread_resp("t1", "m1") 1510 session = MagicMock() 1511 session.__aenter__ = AsyncMock(return_value=session) 1512 session.__aexit__ = AsyncMock(return_value=None) 1513 1514 post_calls = [] 1515 1516 def track_post(url, **kwargs): 1517 post_calls.append({"url": url, "kwargs": kwargs}) 1518 return thread_resp 1519 1520 session.post = MagicMock(side_effect=track_post) 1521 1522 with patch("aiohttp.ClientSession", return_value=session): 1523 result = asyncio.run(_send_discord("tok", "forum_ch", "Hello forum")) 1524 1525 assert result["success"] is True 1526 assert len(post_calls) == 1 1527 # JSON path, no multipart 1528 assert post_calls[0]["kwargs"].get("json") is not None 1529 assert post_calls[0]["kwargs"].get("data") is None 1530 1531 def test_forum_missing_media_file_collected_as_warning(self, tmp_path, monkeypatch): 1532 """Missing media files produce warnings but the thread is still created.""" 1533 monkeypatch.setattr( 1534 "gateway.channel_directory.lookup_channel_type", lambda p, cid: "forum" 1535 ) 1536 1537 thread_resp = self._build_thread_resp() 1538 session = MagicMock() 1539 session.__aenter__ = AsyncMock(return_value=session) 1540 session.__aexit__ = AsyncMock(return_value=None) 1541 session.post = MagicMock(return_value=thread_resp) 1542 1543 with patch("aiohttp.ClientSession", return_value=session): 1544 result = asyncio.run( 1545 _send_discord( 1546 "tok", "forum_ch", "hi", 1547 media_files=[("/nonexistent/does-not-exist.png", False)], 1548 ) 1549 ) 1550 1551 assert result["success"] is True 1552 assert "warnings" in result 1553 assert any("not found" in w for w in result["warnings"]) 1554 1555 1556 # --------------------------------------------------------------------------- 1557 # Tests for the process-local forum-probe cache 1558 # --------------------------------------------------------------------------- 1559 1560 1561 class TestForumProbeCache: 1562 """_DISCORD_CHANNEL_TYPE_PROBE_CACHE memoizes forum detection results.""" 1563 1564 def setup_method(self): 1565 from tools import send_message_tool as smt 1566 smt._DISCORD_CHANNEL_TYPE_PROBE_CACHE.clear() 1567 1568 def test_cache_round_trip(self): 1569 from tools.send_message_tool import ( 1570 _probe_is_forum_cached, 1571 _remember_channel_is_forum, 1572 ) 1573 assert _probe_is_forum_cached("xyz") is None 1574 _remember_channel_is_forum("xyz", True) 1575 assert _probe_is_forum_cached("xyz") is True 1576 _remember_channel_is_forum("xyz", False) 1577 assert _probe_is_forum_cached("xyz") is False 1578 1579 def test_probe_result_is_memoized(self, monkeypatch): 1580 """An API-probed channel type is cached so subsequent sends skip the probe.""" 1581 monkeypatch.setattr( 1582 "gateway.channel_directory.lookup_channel_type", lambda p, cid: None 1583 ) 1584 1585 # First probe response: type=15 (forum) 1586 probe_resp = MagicMock() 1587 probe_resp.status = 200 1588 probe_resp.json = AsyncMock(return_value={"type": 15}) 1589 probe_resp.__aenter__ = AsyncMock(return_value=probe_resp) 1590 probe_resp.__aexit__ = AsyncMock(return_value=None) 1591 1592 thread_resp = MagicMock() 1593 thread_resp.status = 201 1594 thread_resp.json = AsyncMock(return_value={"id": "t1", "message": {"id": "m1"}}) 1595 thread_resp.__aenter__ = AsyncMock(return_value=thread_resp) 1596 thread_resp.__aexit__ = AsyncMock(return_value=None) 1597 1598 probe_session = MagicMock() 1599 probe_session.__aenter__ = AsyncMock(return_value=probe_session) 1600 probe_session.__aexit__ = AsyncMock(return_value=None) 1601 probe_session.get = MagicMock(return_value=probe_resp) 1602 1603 thread_session = MagicMock() 1604 thread_session.__aenter__ = AsyncMock(return_value=thread_session) 1605 thread_session.__aexit__ = AsyncMock(return_value=None) 1606 thread_session.post = MagicMock(return_value=thread_resp) 1607 1608 # Two _send_discord calls: first does probe + thread-create; second should skip probe 1609 from tools import send_message_tool as smt 1610 1611 sessions_created = [] 1612 1613 def session_factory(**kwargs): 1614 # Alternate: each new ClientSession() call returns a probe_session, thread_session pair 1615 idx = len(sessions_created) 1616 sessions_created.append(idx) 1617 # Returns the same mocks; the real code opens a probe session then a thread session. 1618 # Hand out probe_session if this is the first time called within _send_discord, 1619 # otherwise thread_session. 1620 if idx % 2 == 0: 1621 return probe_session 1622 return thread_session 1623 1624 with patch("aiohttp.ClientSession", side_effect=session_factory): 1625 result1 = asyncio.run(_send_discord("tok", "ch1", "first")) 1626 assert result1["success"] is True 1627 assert smt._probe_is_forum_cached("ch1") is True 1628 1629 # Second call: cache hits, no new probe session needed. We need to only 1630 # return thread_session now since probe is skipped. 1631 sessions_created.clear() 1632 with patch("aiohttp.ClientSession", return_value=thread_session): 1633 result2 = asyncio.run(_send_discord("tok", "ch1", "second")) 1634 assert result2["success"] is True 1635 # Only one session opened (thread creation) — no probe session this time 1636 # (verified by not raising from our side_effect exhaustion) 1637 1638 1639 # --------------------------------------------------------------------------- 1640 # _send_signal — chunking + 429 retry (mirrors gateway adapter behavior) 1641 # --------------------------------------------------------------------------- 1642 1643 1644 class _FakeSignalHttp: 1645 """Stand-in for httpx.AsyncClient used as an async context manager. 1646 1647 Pops a response from the queue per `post` call. Each entry is either 1648 a dict (returned from .json()) or an exception instance (raised). 1649 Captures (url, payload) per call. 1650 """ 1651 1652 def __init__(self, responses): 1653 self.responses = list(responses) 1654 self.calls = [] 1655 1656 def __call__(self, *_a, **_kw): 1657 return self 1658 1659 async def __aenter__(self): 1660 return self 1661 1662 async def __aexit__(self, *_a): 1663 return False 1664 1665 async def post(self, url, json=None): 1666 self.calls.append({"url": url, "payload": json}) 1667 if not self.responses: 1668 raise AssertionError("Unexpected extra POST") 1669 item = self.responses.pop(0) 1670 if isinstance(item, BaseException): 1671 raise item 1672 resp = SimpleNamespace( 1673 raise_for_status=lambda: None, 1674 json=lambda data=item: data, 1675 ) 1676 return resp 1677 1678 1679 def _install_signal_http(monkeypatch, fake): 1680 """Patch httpx.AsyncClient at the module level so the lazy import in 1681 _send_signal picks it up. 1682 """ 1683 import httpx 1684 monkeypatch.setattr(httpx, "AsyncClient", fake) 1685 1686 1687 def _patch_sendmsg_sleep_and_time(monkeypatch, capture: list): 1688 """Mock asyncio.sleep + time.monotonic in the signal_rate_limit 1689 module so the scheduler's acquire loop sees synthetic time advancing 1690 during sleep calls, and report_rpc_duration sees the same clock. 1691 1692 Zero-second sleeps (event-loop yields from fake HTTP posts) are 1693 delegated to the real asyncio.sleep so they don't pollute the 1694 capture list. 1695 """ 1696 import asyncio as _aio 1697 _real_sleep = _aio.sleep 1698 offset = [0.0] 1699 1700 async def fake_sleep(seconds): 1701 if seconds > 0: 1702 capture.append(seconds) 1703 offset[0] += seconds 1704 else: 1705 await _real_sleep(0) 1706 1707 monkeypatch.setattr( 1708 "gateway.platforms.signal_rate_limit.asyncio.sleep", fake_sleep 1709 ) 1710 monkeypatch.setattr( 1711 "gateway.platforms.signal_rate_limit.time.monotonic", lambda: offset[0] 1712 ) 1713 1714 1715 class TestSendSignalChunking: 1716 def test_text_only_single_rpc(self, monkeypatch): 1717 fake = _FakeSignalHttp([{"result": {"timestamp": 1}}]) 1718 _install_signal_http(monkeypatch, fake) 1719 1720 result = asyncio.run( 1721 _send_signal( 1722 {"http_url": "http://localhost:8080", "account": "+15551234567"}, 1723 "+15557654321", 1724 "hello", 1725 ) 1726 ) 1727 1728 assert result == {"success": True, "platform": "signal", "chat_id": "+15557654321"} 1729 assert len(fake.calls) == 1 1730 params = fake.calls[0]["payload"]["params"] 1731 assert params["message"] == "hello" 1732 assert "attachments" not in params 1733 1734 def test_chunks_attachments_above_max(self, tmp_path, monkeypatch): 1735 """33 attachments → 2 batches; text only on first batch. Batch 1 1736 only needs 1 token and 18 remain after batch 0, so no sleep.""" 1737 from gateway.platforms.signal_rate_limit import ( 1738 SIGNAL_MAX_ATTACHMENTS_PER_MSG, 1739 ) 1740 1741 paths = [] 1742 for i in range(33): 1743 p = tmp_path / f"img_{i}.png" 1744 p.write_bytes(b"\x89PNG" + b"\x00" * 16) 1745 paths.append((str(p), False)) 1746 1747 fake = _FakeSignalHttp([ 1748 {"result": {"timestamp": 1}}, # batch 0 1749 {"result": {"timestamp": 2}}, # batch 1 1750 ]) 1751 _install_signal_http(monkeypatch, fake) 1752 1753 sleep_calls = [] 1754 _patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls) 1755 1756 result = asyncio.run( 1757 _send_signal( 1758 {"http_url": "http://localhost:8080", "account": "+15551234567"}, 1759 "+15557654321", 1760 "Caption goes here", 1761 media_files=paths, 1762 ) 1763 ) 1764 1765 assert result["success"] is True 1766 assert len(fake.calls) == 2 1767 assert len(sleep_calls) == 0 1768 1769 first = fake.calls[0]["payload"]["params"] 1770 assert first["message"] == "Caption goes here" 1771 assert len(first["attachments"]) == SIGNAL_MAX_ATTACHMENTS_PER_MSG 1772 1773 second = fake.calls[1]["payload"]["params"] 1774 assert second["message"] == "" # caption only on batch 0 1775 assert len(second["attachments"]) == 33 - SIGNAL_MAX_ATTACHMENTS_PER_MSG 1776 1777 def test_full_followup_batch_emits_pacing_notice(self, tmp_path, monkeypatch): 1778 """64 attachments → 2 full batches. Batch 1 needs 14 more tokens 1779 than the 18 remaining after batch 0 — 56s wait crossing the 10s 1780 notice threshold.""" 1781 from gateway.platforms.signal_rate_limit import ( 1782 SIGNAL_MAX_ATTACHMENTS_PER_MSG, 1783 SIGNAL_RATE_LIMIT_BUCKET_CAPACITY, 1784 SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER, 1785 ) 1786 1787 paths = [] 1788 for i in range(64): 1789 p = tmp_path / f"img_{i}.png" 1790 p.write_bytes(b"\x89PNG" + b"\x00" * 16) 1791 paths.append((str(p), False)) 1792 1793 fake = _FakeSignalHttp([ 1794 {"result": {"timestamp": 1}}, # batch 0 1795 {"result": {"timestamp": 99}}, # pacing notice 1796 {"result": {"timestamp": 2}}, # batch 1 1797 ]) 1798 _install_signal_http(monkeypatch, fake) 1799 1800 sleep_calls = [] 1801 _patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls) 1802 1803 result = asyncio.run( 1804 _send_signal( 1805 {"http_url": "http://localhost:8080", "account": "+15551234567"}, 1806 "+15557654321", 1807 "", 1808 media_files=paths, 1809 ) 1810 ) 1811 1812 assert result["success"] is True 1813 assert len(fake.calls) == 3 1814 notice = fake.calls[1]["payload"]["params"] 1815 assert "More images coming" in notice["message"] 1816 assert "attachments" not in notice 1817 # Batch 1 deficit: 32 - (50 - 32) = 14 tokens × 4s = 56s 1818 expected = ( 1819 SIGNAL_MAX_ATTACHMENTS_PER_MSG 1820 - (SIGNAL_RATE_LIMIT_BUCKET_CAPACITY - SIGNAL_MAX_ATTACHMENTS_PER_MSG) 1821 ) * SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER 1822 assert sleep_calls == [pytest.approx(expected, abs=1.0)] 1823 1824 def test_429_with_retry_after_drives_exact_backoff(self, tmp_path, monkeypatch): 1825 """signal-cli ≥ v0.14.3 surfaces Retry-After under 1826 error.data.response.results[*].retryAfterSeconds. The scheduler 1827 calibrates its refill rate from that value; the retry of n=1 1828 sleeps the per-token interval.""" 1829 from gateway.platforms.signal_rate_limit import SIGNAL_RPC_ERROR_RATELIMIT 1830 1831 p = tmp_path / "img.png" 1832 p.write_bytes(b"\x89PNG" + b"\x00" * 16) 1833 1834 fake = _FakeSignalHttp([ 1835 { 1836 "error": { 1837 "code": SIGNAL_RPC_ERROR_RATELIMIT, 1838 "message": "Failed to send message due to rate limiting", 1839 "data": { 1840 "response": { 1841 "timestamp": 0, 1842 "results": [ 1843 {"type": "RATE_LIMIT_FAILURE", "retryAfterSeconds": 42}, 1844 ], 1845 } 1846 }, 1847 } 1848 }, 1849 {"result": {"timestamp": 7}}, 1850 ]) 1851 _install_signal_http(monkeypatch, fake) 1852 1853 sleep_calls = [] 1854 _patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls) 1855 1856 result = asyncio.run( 1857 _send_signal( 1858 {"http_url": "http://localhost:8080", "account": "+15551234567"}, 1859 "+15557654321", 1860 "", 1861 media_files=[(str(p), False)], 1862 ) 1863 ) 1864 1865 assert result["success"] is True 1866 assert len(fake.calls) == 2 # initial + retry 1867 assert sleep_calls == [pytest.approx(42.0, abs=1.0)] 1868 1869 def test_429_without_retry_after_falls_back_to_default(self, tmp_path, monkeypatch): 1870 """Older signal-cli (< v0.14.3) doesn't surface Retry-After. 1871 The scheduler keeps its default rate (1 token / 4s).""" 1872 from gateway.platforms.signal_rate_limit import SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER 1873 1874 p = tmp_path / "img.png" 1875 p.write_bytes(b"\x89PNG" + b"\x00" * 16) 1876 1877 fake = _FakeSignalHttp([ 1878 {"error": {"message": "Failed: [429] Rate Limited"}}, 1879 {"result": {"timestamp": 7}}, 1880 ]) 1881 _install_signal_http(monkeypatch, fake) 1882 1883 sleep_calls = [] 1884 _patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls) 1885 1886 result = asyncio.run( 1887 _send_signal( 1888 {"http_url": "http://localhost:8080", "account": "+15551234567"}, 1889 "+15557654321", 1890 "", 1891 media_files=[(str(p), False)], 1892 ) 1893 ) 1894 1895 assert result["success"] is True 1896 assert sleep_calls == [pytest.approx(SIGNAL_RATE_LIMIT_DEFAULT_RETRY_AFTER, abs=1.0)] 1897 1898 def test_429_retry_exhaust_continues_to_next_batch(self, tmp_path, monkeypatch): 1899 """Both attempts on batch 0 fail; batch 1 still gets a chance. 1900 The scheduler's natural pacing (no more cooldown gate) lets the 1901 second batch through after its acquire wait.""" 1902 from gateway.platforms.signal_rate_limit import SIGNAL_RPC_ERROR_RATELIMIT 1903 1904 paths = [] 1905 for i in range(33): # forces 2 batches 1906 p = tmp_path / f"img_{i}.png" 1907 p.write_bytes(b"\x89PNG" + b"\x00" * 16) 1908 paths.append((str(p), False)) 1909 1910 rate_limit_err = { 1911 "error": { 1912 "code": SIGNAL_RPC_ERROR_RATELIMIT, 1913 "message": "Failed to send message due to rate limiting", 1914 "data": { 1915 "response": { 1916 "timestamp": 0, 1917 "results": [ 1918 {"type": "RATE_LIMIT_FAILURE", "retryAfterSeconds": 4}, 1919 ], 1920 } 1921 }, 1922 } 1923 } 1924 1925 fake = _FakeSignalHttp([ 1926 rate_limit_err, # batch 0, attempt 1 1927 rate_limit_err, # batch 0, attempt 2 (exhaust) 1928 {"result": {"timestamp": 9}}, # batch 1 succeeds 1929 ]) 1930 _install_signal_http(monkeypatch, fake) 1931 1932 sleep_calls = [] 1933 _patch_sendmsg_sleep_and_time(monkeypatch, sleep_calls) 1934 1935 result = asyncio.run( 1936 _send_signal( 1937 {"http_url": "http://localhost:8080", "account": "+15551234567"}, 1938 "+15557654321", 1939 "many", 1940 media_files=paths, 1941 ) 1942 ) 1943 1944 # Partial success: batch 0 lost but batch 1 went through. 1945 assert result["success"] is True 1946 assert "warnings" in result 1947 assert any("rate-limited" in w for w in result["warnings"]) 1948 # 2 attempts on batch 0 + 1 successful batch 1 = 3 calls 1949 assert len(fake.calls) == 3 1950 1951 def test_non_rate_limit_error_returns_immediately(self, tmp_path, monkeypatch): 1952 """A non-429 RPC error should not retry — it returns an error result.""" 1953 p = tmp_path / "img.png" 1954 p.write_bytes(b"\x89PNG" + b"\x00" * 16) 1955 1956 fake = _FakeSignalHttp([ 1957 {"error": {"message": "UntrustedIdentityException"}}, 1958 ]) 1959 _install_signal_http(monkeypatch, fake) 1960 1961 result = asyncio.run( 1962 _send_signal( 1963 {"http_url": "http://localhost:8080", "account": "+15551234567"}, 1964 "+15557654321", 1965 "", 1966 media_files=[(str(p), False)], 1967 ) 1968 ) 1969 1970 assert "error" in result 1971 assert "UntrustedIdentityException" in result["error"] 1972 assert len(fake.calls) == 1 # no retry on non-429 1973 1974 def test_skipped_missing_files_reported_in_warnings(self, tmp_path, monkeypatch): 1975 good = tmp_path / "ok.png" 1976 good.write_bytes(b"\x89PNG" + b"\x00" * 16) 1977 1978 fake = _FakeSignalHttp([{"result": {"timestamp": 1}}]) 1979 _install_signal_http(monkeypatch, fake) 1980 1981 result = asyncio.run( 1982 _send_signal( 1983 {"http_url": "http://localhost:8080", "account": "+15551234567"}, 1984 "+15557654321", 1985 "msg", 1986 media_files=[(str(good), False), (str(tmp_path / "missing.png"), False)], 1987 ) 1988 ) 1989 1990 assert result["success"] is True 1991 assert "warnings" in result 1992 # Only the existing file made it into the RPC 1993 params = fake.calls[0]["payload"]["params"] 1994 assert len(params["attachments"]) == 1