from __future__ import annotations # ruff: noqa: E402 import base64 import hashlib import os import struct os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") import pytest from fastapi import FastAPI from fastapi.testclient import TestClient from pydantic import ValidationError from src.api.v1 import agents as agents_api from src.core.config import settings from src.models.agent99_completion import Agent99TelegramLifecycleRequest from src.services import agent99_telegram_lifecycle as lifecycle_service from src.services import telegram_gateway as gateway_service def payload() -> dict: return { "schema_version": "agent99_telegram_lifecycle_v1", "delivery_id": "agent99-lifecycle-0123456789abcdef0123456789abcdef01234567", "project_id": "awoooi", "incident_id": "INC-20260715-AG9901", "fingerprint": "0123456789abcdef0123456789abcdef", "event_type": "performance_warning", "severity": "warning", "lifecycle": "investigating", "state_key": "investigating|warning", "run_id": "run-agent99-20260715-01", "title": "Host 110 主機效能異常", "target": "192.168.0.110", "impact": "資源壓力可能降低服務回應速度。", "action": "Agent99 正在執行 allowlisted 診斷與降載。", "verification": "等待 CPU、記憶體與磁碟 post-verifier。", "duration_text": "12.4 秒", "occurrences": 2, "next_update": "狀態改變時更新,相同狀態不重複推播。", "occurred_at": "2026-07-15T04:20:00+08:00", } def visual_payload() -> dict: raw = ( b"\x89PNG\r\n\x1a\n" + struct.pack(">I", 13) + b"IHDR" + struct.pack(">II", 1200, 760) + b"\x08\x02\x00\x00\x00" ) return { "schema_version": "agent99_telegram_visual_v1", "media_type": "image/png", "generated_by": "agent99_system_drawing_v1", "width": 1200, "height": 760, "sha256": hashlib.sha256(raw).hexdigest(), "content_base64": base64.b64encode(raw).decode(), } def app_client() -> TestClient: app = FastAPI() app.include_router(agents_api.router, prefix="/api/v1") return TestClient(app) def test_lifecycle_payload_rejects_unbounded_content() -> None: with pytest.raises(ValidationError): Agent99TelegramLifecycleRequest.model_validate( {**payload(), "raw_log": "must-not-enter-lifecycle-ingress"} ) with pytest.raises(ValidationError): Agent99TelegramLifecycleRequest.model_validate( {**payload(), "incident_id": r"C:\Wooo\Agent99\evidence\raw.json"} ) with pytest.raises(ValidationError): Agent99TelegramLifecycleRequest.model_validate( {**payload(), "project_id": "unregistered-product"} ) with pytest.raises(ValidationError): Agent99TelegramLifecycleRequest.model_validate( { **payload(), "visual": {**visual_payload(), "sha256": "0" * 64}, } ) def test_visual_caption_stays_within_telegram_limit_without_broken_html() -> None: request = Agent99TelegramLifecycleRequest.model_validate( { **payload(), "title": "&" * 120, "target": "&" * 120, "action": "&" * 360, "verification": "&" * 300, "duration_text": "&" * 80, } ) caption = lifecycle_service._format_lifecycle_caption(request) assert len(caption) <= 1024 assert caption.count("") == caption.count("") == 2 assert caption.count("") == caption.count("") == 1 assert "&" not in caption.replace("&", "") def test_lifecycle_endpoint_rejects_wrong_token(monkeypatch) -> None: monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected") response = app_client().post( "/api/v1/agents/agent99/telegram-lifecycle", headers={"X-Agent99-Lifecycle-Token": "wrong"}, json=payload(), ) assert response.status_code == 401 assert response.json()["detail"] == "agent99_lifecycle_auth_failed" def test_lifecycle_endpoint_requires_durable_gateway_ack(monkeypatch) -> None: monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected") async def failed_delivery(request): return { "ok": False, "delivery_id": request.delivery_id, "delivery_status": "pending_unknown", } monkeypatch.setattr( agents_api, "deliver_agent99_telegram_lifecycle", failed_delivery, ) response = app_client().post( "/api/v1/agents/agent99/telegram-lifecycle", headers={"X-Agent99-Lifecycle-Token": "expected"}, json=payload(), ) assert response.status_code == 503 assert "pending_unknown" in response.json()["detail"] def test_lifecycle_endpoint_returns_same_delivery_receipt(monkeypatch) -> None: monkeypatch.setattr(settings, "AGENT99_SRE_ALERT_RELAY_TOKEN", "expected") async def successful_delivery(request): return { "schema_version": "agent99_telegram_lifecycle_receipt_v1", "ok": True, "delivery_id": request.delivery_id, "incident_id": request.incident_id, "delivery_status": "sent", "provider_message_id": "8123", "durable_outbound_acknowledged": True, } monkeypatch.setattr( agents_api, "deliver_agent99_telegram_lifecycle", successful_delivery, ) response = app_client().post( "/api/v1/agents/agent99/telegram-lifecycle", headers={"X-Agent99-Lifecycle-Token": "expected"}, json=payload(), ) assert response.status_code == 200 assert response.json()["delivery_id"] == payload()["delivery_id"] assert response.json()["provider_message_id"] == "8123" def test_lifecycle_outbox_identity_is_stable_and_separate() -> None: base = { "callback_reply": { "project_id": "awoooi", "automation_run_id": "agent99-lifecycle-0123456789", "incident_id": "INC-20260715-AG9901", "apply_op_id": "investigating|warning", } } lifecycle_source = { "callback_reply": { **base["callback_reply"], "action": "agent99_lifecycle", } } controlled_source = { "callback_reply": { **base["callback_reply"], "action": "controlled_apply_result", } } lifecycle_identity = gateway_service._controlled_apply_result_delivery_identity( lifecycle_source ) controlled_identity = gateway_service._controlled_apply_result_delivery_identity( controlled_source ) assert lifecycle_identity is not None assert controlled_identity is not None assert lifecycle_identity["delivery_kind"] == "agent99_lifecycle" assert controlled_identity["delivery_kind"] == "controlled_apply_result" assert gateway_service._controlled_apply_result_delivery_run_id( lifecycle_identity ) == gateway_service._controlled_apply_result_delivery_run_id(lifecycle_identity) assert gateway_service._controlled_apply_result_delivery_run_id( lifecycle_identity ) != gateway_service._controlled_apply_result_delivery_run_id(controlled_identity) @pytest.mark.asyncio async def test_lifecycle_service_uses_durable_canonical_sender(monkeypatch) -> None: calls: list[dict] = [] class Gateway: async def send_agent99_lifecycle_receipt(self, **kwargs): calls.append(kwargs) return { "ok": True, "result": {"message_id": 9123}, "_awooop_outbound_mirror_acknowledged": True, "_awooop_delivery_status": "sent", "_awooop_provider_send_performed": True, "_awooop_delivery_context": { "destination_binding_verified": True, }, } monkeypatch.setattr( lifecycle_service, "get_telegram_gateway", lambda: Gateway(), ) request = Agent99TelegramLifecycleRequest.model_validate(payload()) receipt = await lifecycle_service.deliver_agent99_telegram_lifecycle(request) assert receipt["ok"] is True assert receipt["durable_outbound_acknowledged"] is True assert receipt["destination_binding_verified"] is True assert receipt["provider_message_id"] == "9123" assert calls[0]["priority"] == "P1" assert "Agent99 事件" in calls[0]["text"] assert "AI 處置" in calls[0]["text"] assert "raw_log" not in calls[0]["text"] @pytest.mark.asyncio async def test_visual_lifecycle_uses_one_durable_send_photo_receipt(monkeypatch) -> None: calls: list[dict] = [] class Gateway: async def send_agent99_lifecycle_receipt(self, **kwargs): calls.append(kwargs) return { "ok": True, "result": {"message_id": 9124}, "_awooop_outbound_mirror_acknowledged": True, "_awooop_delivery_status": "sent", "_awooop_provider_send_performed": True, "_awooop_visual_delivery_verified": True, "_awooop_delivery_context": { "destination_binding_verified": True, }, } monkeypatch.setattr( lifecycle_service, "get_telegram_gateway", lambda: Gateway(), ) request = Agent99TelegramLifecycleRequest.model_validate( {**payload(), "visual": visual_payload()} ) receipt = await lifecycle_service.deliver_agent99_telegram_lifecycle(request) assert receipt["ok"] is True assert receipt["visual_requested"] is True assert receipt["visual_sent"] is True assert receipt["raw_visual_stored"] is False assert calls[0]["visual_png"].startswith(b"\x89PNG") assert calls[0]["visual_sha256"] == visual_payload()["sha256"] assert len(calls[0]["text"]) <= 1024 assert "Agent99:" in calls[0]["text"] @pytest.mark.asyncio async def test_reused_legacy_text_receipt_does_not_claim_visual_delivery( monkeypatch, ) -> None: class Gateway: async def send_agent99_lifecycle_receipt(self, **kwargs): return { "ok": True, "result": {"message_id": 7001}, "_awooop_outbound_mirror_acknowledged": True, "_awooop_delivery_status": "sent_reused", "_awooop_provider_send_performed": False, } monkeypatch.setattr( lifecycle_service, "get_telegram_gateway", lambda: Gateway(), ) request = Agent99TelegramLifecycleRequest.model_validate( {**payload(), "visual": visual_payload()} ) receipt = await lifecycle_service.deliver_agent99_telegram_lifecycle(request) assert receipt["ok"] is True assert receipt["visual_requested"] is True assert receipt["visual_sent"] is False assert receipt["visual_delivery_status"] == "sent_reused" @pytest.mark.asyncio async def test_gateway_builds_primary_photo_with_same_send_once_identity() -> None: calls: list[tuple[str, dict]] = [] gateway = object.__new__(gateway_service.TelegramGateway) async def capture(method: str, outgoing: dict) -> dict: calls.append((method, outgoing)) return {"ok": True} gateway._send_request = capture image = Agent99TelegramLifecycleRequest.model_validate( {**payload(), "visual": visual_payload()} ).visual assert image is not None await gateway.send_agent99_lifecycle_receipt( delivery_id=payload()["delivery_id"], incident_id=payload()["incident_id"], state_key=payload()["state_key"], text="short caption", priority="P1", visual_png=image.png_bytes(), visual_sha256=image.sha256, ) method, outgoing = calls[0] assert method == "sendPhoto" assert outgoing["photo"] == "attach://photo" assert outgoing["caption"] == "short caption" assert outgoing[gateway_service._BINARY_UPLOAD_KEY]["content"].startswith( b"\x89PNG" ) source = outgoing[gateway_service._AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY] assert source["callback_reply"]["automation_run_id"] == payload()["delivery_id"] assert source["visual_delivery"]["raw_visual_stored"] is False @pytest.mark.asyncio async def test_gateway_posts_visual_as_multipart_without_binary_json(monkeypatch) -> None: requests: list[tuple[str, dict]] = [] class Response: def raise_for_status(self) -> None: return None def json(self) -> dict: return { "ok": True, "result": {"message_id": 9125, "chat": {"id": "-10099"}}, } class Client: async def post(self, url: str, **kwargs): requests.append((url, kwargs)) return Response() monkeypatch.setattr(settings, "OPENCLAW_TG_BOT_TOKEN", "test-token") gateway = object.__new__(gateway_service.TelegramGateway) gateway._initialized = True gateway._http_client = Client() binding = gateway_service._telegram_destination_binding("-10099") gateway._authorize_canonical_send_message = lambda **_: ( "-10099", { "schema_version": "telegram_canonical_egress_receipt_v1", "decision": "allow", "classifier": "canonical_route", "product_id": "awoooi", "signal_family": "incident_lifecycle", "severity": "P1", "destination_alias": "telegram_chat_alias:awoooi_sre_war_room", "destination_binding": binding, "sender_bot_alias": "tsenyang_bot", "provider_send_performed": False, }, ) image = Agent99TelegramLifecycleRequest.model_validate( {**payload(), "visual": visual_payload()} ).visual assert image is not None receipt = await gateway._send_request( "sendPhoto", { "photo": "attach://photo", "caption": "Agent99 visual test", gateway_service._CANONICAL_ROUTE_CONTEXT_KEY: { "product_id": "awoooi", "signal_family": "incident_lifecycle", "severity": "P1", }, gateway_service._BINARY_UPLOAD_KEY: { "field_name": "photo", "filename": "agent99-card.png", "content_type": "image/png", "content": image.png_bytes(), }, }, ) assert receipt["_awooop_delivery_status"] == "sent" _, request_kwargs = requests[0] assert "json" not in request_kwargs assert request_kwargs["data"]["photo"] == "attach://photo" assert request_kwargs["files"]["photo"][0] == "agent99-card.png" assert request_kwargs["files"]["photo"][1].startswith(b"\x89PNG")