/ tests / tools / test_send_message_tool.py
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 '&gt;')."""
 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 "&amp;" in sent_text  # & is escaped
 440          assert "&gt;" 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&amp;T &lt;tag&gt; test",
 455                  )
 456              )
 457          assert result["success"] is True
 458          sent_text = send.await_args.args[2]
 459          assert "&amp;amp;" not in sent_text
 460          assert "&amp;lt;" not in sent_text
 461          assert "AT&amp;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