"""Native diagnostic producers keep captions, controls and truthful receipts. No live platform connections: actual adapters run against recording transports. """ import json from types import SimpleNamespace from unittest.mock import AsyncMock import pytest from gateway.config import PlatformConfig from gateway.platforms.base import SendResult from plugins.platforms.buzz.adapter import BuzzAdapter from plugins.platforms.discord.adapter import DiscordAdapter from plugins.platforms.google_chat.adapter import GoogleChatAdapter from plugins.platforms.matrix.adapter import MatrixAdapter from plugins.platforms.wecom.adapter import WeComAdapter @pytest.fixture(params=[None, False, True, "override"]) def policy(request, tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path)) setting = request.param if setting is not None: display = {"suppress_warning_notifications": setting is True} if setting == "override": display["platforms"] = { name: {"suppress_warning_notifications": True} for name in ("matrix", "google_chat", "discord", "wecom", "buzz", "mattermost", "feishu", "telegram") } (tmp_path / "config.yaml").write_text(json.dumps({"display": display})) return setting is True or setting == "override" @pytest.mark.asyncio @pytest.mark.parametrize("caption", [None, "Error: this is the requested caption"]) async def test_matrix_download_diagnostic_not_caption_is_suppressed(policy, caption, monkeypatch, caplog): adapter = MatrixAdapter(PlatformConfig()) frames = [] async def record(room, event_type, content): frames.append(content) return "$sent" adapter._client = SimpleNamespace(send_message_event=record) adapter._download_external_media_with_cap = AsyncMock(side_effect=RuntimeError("download fixture")) monkeypatch.setattr("tools.url_safety.is_safe_url", lambda url: True) result = await adapter.send_image("!room", "https://example.invalid/image?token=private", caption, "$reply", {"thread_id": "$thread"}) if policy: assert [f["body"] for f in frames] == ([caption] if caption else []) assert not result.success and result.message_id is None if frames: assert frames[0]["m.relates_to"]["event_id"] == "$thread" else: from agent.i18n import t notice = t("platform.matrix.media.image_download_failed") assert [f["body"] for f in frames] == [f"{caption}\n{notice}" if caption else notice] assert result.success and result.message_id == "$sent" assert "failed to download image" in caplog.text @pytest.mark.asyncio @pytest.mark.parametrize("caption", [None, "Warning: requested document caption"]) async def test_google_chat_oauth_fallback_only_hides_diagnostic(policy, caption, tmp_path): adapter = GoogleChatAdapter(PlatformConfig()) frames = [] async def record(chat, body): frames.append(body) return SendResult(success=True, message_id="spaces/test/messages/sent") adapter._create_message = record adapter._acquire_user_chat_api = AsyncMock(return_value=(None, None)) path = tmp_path / "report.pdf" path.write_bytes(b"fixture") result = await adapter.send_document("spaces/test", str(path), caption=caption, metadata={"thread_id": "spaces/test/threads/thread"}) assert not result.success and "OAuth" in result.error if policy: assert [f["text"] for f in frames] == ([caption] if caption else []) else: assert len(frames) == 1 and "/setup-files" in frames[0]["text"] assert str(path) in frames[0]["text"] if caption: assert frames[0]["text"].startswith(caption + "\n") if frames: assert frames[0]["thread"]["name"] == "spaces/test/threads/thread" # An explicit control request must still use the same transport. await adapter.send_clarify("spaces/test", "Continue?", ["yes", "no"], "clarify", "session") assert "cardsV2" in frames[-1] @pytest.mark.asyncio @pytest.mark.parametrize("method", ["send_document", "send_voice", "send_multiple_images"]) @pytest.mark.parametrize("forum", [False, True]) async def test_discord_rejected_media_preserves_caption_without_fake_receipt(policy, method, forum, tmp_path, caplog): adapter = DiscordAdapter(PlatformConfig()) frames = [] async def record(**payload): frames.append(payload) return SimpleNamespace(id=123, attachments=[]) async def create_thread(**payload): frames.append(payload) return SimpleNamespace(thread=SimpleNamespace(id=101), message=SimpleNamespace(id=102)) channel = SimpleNamespace(send=record, id=99, create_thread=create_thread) adapter._client = SimpleNamespace() adapter._resolve_channel = AsyncMock(return_value=channel) adapter._is_forum_parent = lambda channel: forum adapter._discord_upload_limit_bytes = lambda channel: 1 path = tmp_path / "large.png" path.write_bytes(b"too large") caption = "Warning: requested caption" if method == "send_multiple_images": result = await adapter.send_multiple_images("99", [(path.as_uri(), caption)]) else: result = await getattr(adapter, method)("99", str(path), caption=caption) assert not result.success and result.message_id is None if policy: assert [f["content"] for f in frames] == [caption] elif forum: assert frames == [] # legacy forum rejection sends no notice else: assert len(frames) == 1 and "⚠️" in frames[0]["content"] assert caption not in frames[0]["content"] # preserve legacy default assert "large" in caplog.text @pytest.mark.asyncio async def test_wecom_rejected_media_keeps_caption_and_failed_result(policy): adapter = WeComAdapter(PlatformConfig()) frames = [] async def record(command, body, **kwargs): frames.append(body) return {"errcode": 0, "headers": {"req_id": "sent"}} adapter._send_request = record adapter._prepare_outbound_media = AsyncMock(return_value={"rejected": True, "reject_reason": "fixture rejection"}) result = await adapter.send_document("chat", "fixture.pdf", caption="Error: requested caption") assert not result.success and result.error == "fixture rejection" assert [f["markdown"]["content"] for f in frames] == (["Error: requested caption"] if policy else ["⚠️ fixture rejection"]) @pytest.mark.asyncio async def test_buzz_missing_upload_fallback_and_real_upload_receipts(policy, tmp_path): adapter = BuzzAdapter(PlatformConfig(extra={"cli_path": "fixture-buzz"})) adapter._mention_pubkeys_for = AsyncMock(return_value=[]) frames = [] async def record(args, content, *extra): frames.append((args, content)) return 0, json.dumps({"event_id": "a" * 64, "accepted": True}), "" adapter._run_message_send = record result = await adapter.send_image_file("chat", str(tmp_path / "missing.png"), caption="requested caption") assert frames[0][1] == ("requested caption" if policy else "requested caption\n⚠️ Couldn't deliver the image attachment.") assert result.success is (not policy) path = tmp_path / "image.png" path.write_bytes(b"fixture") result = await adapter.send_image_file("chat", str(path), caption="⚠️ user caption") assert result.success and result.message_id == "a" * 64 assert "--file" in frames[-1][0] and frames[-1][1] == "⚠️ user caption" @pytest.mark.asyncio @pytest.mark.parametrize("lane", ["send", "edit"]) async def test_discord_truncation_warning_covers_send_and_final_edit(policy, lane): adapter = DiscordAdapter(PlatformConfig()) frames = [] async def record(**payload): frames.append(payload["content"]) return SimpleNamespace(id=123, to_reference=lambda **kwargs: None) message = SimpleNamespace(id=123, edit=record, to_reference=lambda **kwargs: None) channel = SimpleNamespace(send=record, get_partial_message=lambda ident: message, id=99) adapter._client = SimpleNamespace() adapter._resolve_channel = AsyncMock(return_value=channel) adapter._is_forum_parent = lambda channel: False adapter._record_response_async = AsyncMock() adapter.MAX_MESSAGE_LENGTH = 80 adapter.MAX_SPLIT_MESSAGES = 3 text = "Warning: requested result. " * 40 if lane == "send": result = await adapter.send("99", text) else: result = await adapter.edit_message("99", "123", text, finalize=True) assert result.success assert frames[0].startswith("Warning: requested result.") assert any("Response truncated" in frame for frame in frames) is (not policy) assert len(frames) == (2 if policy else 3) @pytest.mark.asyncio async def test_wecom_downgrade_preserves_native_upload_and_caption(policy): adapter = WeComAdapter(PlatformConfig()) frames = [] async def record(cmd, body, **kwargs): frames.append((cmd, body)) return {"errcode": 0, "body": {"upload_id": "upload", "media_id": "media"}, "headers": {"req_id": "sent"}} adapter._send_request = record adapter._load_outbound_media = AsyncMock(return_value=(b"fixture", "audio/ogg", "voice.ogg")) result = await adapter.send_voice("chat", "voice.ogg", caption="Error: user caption") assert result.success assert any(body.get("file", {}).get("media_id") == "media" for _, body in frames) text = [body["markdown"]["content"] for _, body in frames if "markdown" in body] assert text[0] == "Error: user caption" assert len(text) == (1 if policy else 2) @pytest.mark.asyncio async def test_google_chat_revoked_oauth_fallback_uses_same_policy(policy, tmp_path, monkeypatch, caplog): from plugins.platforms.google_chat import adapter as google class Revoked(Exception): resp = SimpleNamespace(status=403) def fail(**kwargs): raise Revoked("token revoked") adapter = GoogleChatAdapter(PlatformConfig()) adapter._acquire_user_chat_api = AsyncMock(return_value=(SimpleNamespace(media=lambda: SimpleNamespace(upload=fail)), "owner")) adapter._user_creds_by_email["owner"] = object() adapter._user_chat_api_by_email["owner"] = object() monkeypatch.setattr(google, "HttpError", Revoked) monkeypatch.setattr(google, "MediaFileUpload", lambda *args, **kwargs: object()) frames = [] async def record(chat_id, body): frames.append(body) return SendResult(success=True, message_id="sent") adapter._create_message = record path = tmp_path / "file.pdf" path.write_bytes(b"fixture") result = await adapter.send_document("chat", str(path), caption="requested caption") assert not result.success assert "owner" not in adapter._user_creds_by_email assert "owner" not in adapter._user_chat_api_by_email assert "media.upload auth failure" in caplog.text assert frames[0]["text"] == "requested caption" if policy else "/setup-files" in frames[0]["text"] @pytest.mark.asyncio async def test_mattermost_thread_fallback_keeps_requested_result(policy, caplog): from plugins.platforms.mattermost.adapter import MattermostAdapter adapter = MattermostAdapter(PlatformConfig(extra={"reply_mode": "thread"})) frames = [] async def api(method, path, payload=None): frames.append(dict(payload)) if "root_id" in payload: adapter._last_post_status = 404 adapter._last_post_error = "root_id not found" return {} return {"id": "sent"} adapter._api = api adapter._resolve_root_id = AsyncMock(return_value="root") result = await adapter.send("chat", "Error: requested result", metadata={"thread_id": "root", "notify": True}) assert result.success assert frames[0]["root_id"] == "root" and "root_id" not in frames[1] expected = "Error: requested result" if not policy: expected = "⚠️ Mattermost thread delivery failed; posting final reply in channel.\n\n" + expected assert frames[1]["message"] == expected assert "falling back to flat channel" in caplog.text @pytest.mark.asyncio async def test_feishu_downgrade_keeps_attachment_and_caption(policy, tmp_path): from plugins.platforms.feishu.adapter import FeishuAdapter adapter = FeishuAdapter(PlatformConfig()) frames = [] path = tmp_path / "animation.gif" path.write_bytes(b"fixture") adapter._download_remote_document = AsyncMock(return_value=(str(path), path.name)) def upload(request): return SimpleNamespace(success=lambda: True, code=0, data=SimpleNamespace(file_key="file-key")) async def send_raw(**kwargs): frames.append(kwargs) return SimpleNamespace(success=lambda: True, code=0, data=SimpleNamespace(message_id="sent")) adapter._client = SimpleNamespace(im=SimpleNamespace(v1=SimpleNamespace(file=SimpleNamespace(create=upload)))) adapter._send_raw_message = send_raw result = await adapter.send_animation("chat", "https://example.invalid/animation.gif", caption="Warning: requested caption") assert result.success assert "file-key" in frames[0]["payload"] assert "Warning: requested caption" in frames[0]["payload"] assert ("GIF downgraded to file" in frames[0]["payload"]) is (not policy) @pytest.mark.asyncio async def test_telegram_cache_warning_does_not_hide_inbound_context(policy): from plugins.platforms.telegram.adapter import TelegramAdapter adapter = TelegramAdapter(PlatformConfig()) frames = [] async def reply_text(text): frames.append(text) from gateway.platforms.event import MessageEvent event = MessageEvent(text="Requested content", source=adapter.build_source(chat_id="42", user_id="42")) await adapter._surface_media_cache_failure(SimpleNamespace(reply_text=reply_text), event, "photo", RuntimeError("fixture")) assert bool(frames) is (not policy) assert event.text.startswith("Requested content") assert "could not be downloaded" in event.text assert ("asked to retry" in event.text) is (not policy)