diff --git a/agent99-control-plane.ps1 b/agent99-control-plane.ps1 index 318dc3879..6ccd58a62 100644 --- a/agent99-control-plane.ps1 +++ b/agent99-control-plane.ps1 @@ -1859,6 +1859,39 @@ function Send-AgentTelegram { return $attempt } + $visualPayload = $null + $generatedVisualPath = New-AgentTelegramCardImage $Severity $EventType $Message $Data + if ($generatedVisualPath -and (Test-Path -LiteralPath $generatedVisualPath)) { + try { + $visualBytes = [IO.File]::ReadAllBytes($generatedVisualPath) + if ($visualBytes.Length -gt 1048576) { + $attempt.visualError = "card_image_exceeds_1_mib" + } else { + $sha256 = [Security.Cryptography.SHA256]::Create() + try { + $visualHash = [BitConverter]::ToString($sha256.ComputeHash($visualBytes)).Replace("-", "").ToLowerInvariant() + } finally { + $sha256.Dispose() + } + $visualPayload = [ordered]@{ + schema_version = "agent99_telegram_visual_v1" + media_type = "image/png" + generated_by = "agent99_system_drawing_v1" + width = 1200 + height = 760 + sha256 = $visualHash + content_base64 = [Convert]::ToBase64String($visualBytes) + } + $attempt.visualPath = $generatedVisualPath + } + } catch { + $attempt.visualError = "card_image_read_failed" + Write-AgentLog "telegram_card_image_read_failed event=$EventType error=$($_.Exception.Message)" + } + } elseif ($Severity -in @("warning", "critical")) { + $attempt.visualError = "card_image_render_failed" + } + $deliverySource = "$($card.incidentId)|$($card.fingerprint)|$($card.stateKey)|$($card.runKey)" $deliveryHashBytes = [Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($deliverySource)) $deliveryHash = [BitConverter]::ToString($deliveryHashBytes).Replace("-", "").ToLowerInvariant() @@ -1884,6 +1917,7 @@ function Send-AgentTelegram { occurrences = [int]$card.occurrences next_update = [string]$card.nextUpdate reply_to_message_id = if ($replyToMessageId) { [int64]$replyToMessageId } else { $null } + visual = $visualPayload occurred_at = (Get-Date -Format o) } @@ -1904,6 +1938,10 @@ function Send-AgentTelegram { $providerMessageId ) $attempt.messageId = if ($providerMessageId) { $providerMessageId } else { $null } + $attempt.visualSent = Convert-AgentBool (Get-AgentObjectValue $response "visual_sent" $false) + if ($visualPayload -and -not $attempt.visualSent -and -not $attempt.visualError) { + $attempt.visualError = [string](Get-AgentObjectValue $response "visual_delivery_status" "visual_delivery_not_verified") + } $attempt.sent = $deliveryOk $attempt.error = if ($deliveryOk) { $null } else { "canonical_gateway_receipt_not_verified" } $attempt.relay = [pscustomobject]@{ diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index e37dd4b20..cf81caaa4 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -1283,7 +1283,8 @@ async def preview_controlled_alert_target( description=( "以 Agent99 relay token 驗證 Windows 99 結構化事件卡,交由 AWOOOI " "canonical Telegram gateway 以 send-once outbox 傳送並回讀 durable ack。" - "此端點不接受 Bot token、任意訊息文字、raw log 或檔案內容。" + "此端點不接受 Bot token、任意訊息文字、raw log 或檔案路徑;" + "視覺附件只接受固定 1200x760、1 MiB 內且 SHA-256 相符的 Agent99 PNG。" ), ) async def post_agent99_telegram_lifecycle( diff --git a/apps/api/src/models/agent99_completion.py b/apps/api/src/models/agent99_completion.py index 2e87798cd..c115ae871 100644 --- a/apps/api/src/models/agent99_completion.py +++ b/apps/api/src/models/agent99_completion.py @@ -2,10 +2,15 @@ from __future__ import annotations +import base64 +import binascii +import hashlib +import secrets +import struct from datetime import datetime from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator class Agent99TelegramReceipt(BaseModel): @@ -75,6 +80,50 @@ class Agent99CompletionCallbackRequest(BaseModel): occurred_at: datetime +class Agent99TelegramVisual(BaseModel): + """Bounded Agent99-rendered PNG; never accepts paths or arbitrary media.""" + + model_config = ConfigDict(extra="forbid", str_strip_whitespace=True) + + schema_version: Literal["agent99_telegram_visual_v1"] + media_type: Literal["image/png"] + generated_by: Literal["agent99_system_drawing_v1"] + width: Literal[1200] + height: Literal[760] + sha256: str = Field(pattern=r"^[0-9a-f]{64}$") + content_base64: str = Field( + min_length=32, + max_length=1_400_000, + pattern=r"^[A-Za-z0-9+/]+={0,2}$", + repr=False, + ) + + @model_validator(mode="after") + def validate_bounded_png(self) -> Agent99TelegramVisual: + try: + raw = base64.b64decode(self.content_base64, validate=True) + except (ValueError, binascii.Error) as exc: + raise ValueError("agent99_visual_base64_invalid") from exc + if len(raw) > 1024 * 1024: + raise ValueError("agent99_visual_png_exceeds_1_mib") + if ( + len(raw) < 24 + or raw[:8] != b"\x89PNG\r\n\x1a\n" + or raw[12:16] != b"IHDR" + ): + raise ValueError("agent99_visual_png_header_invalid") + width, height = struct.unpack(">II", raw[16:24]) + if width != self.width or height != self.height: + raise ValueError("agent99_visual_dimensions_invalid") + digest = hashlib.sha256(raw).hexdigest() + if not secrets.compare_digest(digest, self.sha256): + raise ValueError("agent99_visual_sha256_mismatch") + return self + + def png_bytes(self) -> bytes: + return base64.b64decode(self.content_base64, validate=True) + + class Agent99TelegramLifecycleRequest(BaseModel): """Bounded Agent99 event card handed to the canonical Telegram gateway.""" @@ -119,4 +168,5 @@ class Agent99TelegramLifecycleRequest(BaseModel): occurrences: int = Field(default=1, ge=1, le=1_000_000) next_update: str = Field(min_length=1, max_length=300) reply_to_message_id: int | None = Field(default=None, ge=1) + visual: Agent99TelegramVisual | None = None occurred_at: datetime diff --git a/apps/api/src/services/agent99_telegram_lifecycle.py b/apps/api/src/services/agent99_telegram_lifecycle.py index 799e93ef1..898d97c49 100644 --- a/apps/api/src/services/agent99_telegram_lifecycle.py +++ b/apps/api/src/services/agent99_telegram_lifecycle.py @@ -8,7 +8,6 @@ from typing import Any from src.models.agent99_completion import Agent99TelegramLifecycleRequest from src.services.telegram_gateway import get_telegram_gateway - _LIFECYCLE_LABELS = { "detected": "已偵測 / DETECTED", "investigating": "調查中 / INVESTIGATING", @@ -53,19 +52,60 @@ def _format_lifecycle_card(payload: Agent99TelegramLifecycleRequest) -> str: )[:4096] +def _format_lifecycle_caption(payload: Agent99TelegramLifecycleRequest) -> str: + """Keep the visual card caption scannable on mobile Telegram clients.""" + + def esc(value: object, limit: int) -> str: + rendered: list[str] = [] + rendered_length = 0 + truncated = False + for character in str(value or ""): + encoded = html.escape(character) + if rendered_length + len(encoded) > limit - 3: + truncated = True + break + rendered.append(encoded) + rendered_length += len(encoded) + return "".join(rendered) + ("..." if truncated else "") + + return "\n".join( + [ + ( + f"{esc(_LIFECYCLE_LABELS[payload.lifecycle], 48)}|" + f"{esc(_SEVERITY_LABELS[payload.severity], 32)}" + ), + f"{esc(payload.title, 100)}", + f"資產:{esc(payload.target, 80)}", + f"Agent99:{esc(payload.action, 220)}", + f"驗證:{esc(payload.verification, 180)}", + ( + f"事件 {esc(payload.incident_id, 180)}|" + f"發生 {payload.occurrences} 次|{esc(payload.duration_text, 60)}" + ), + ] + ) + + async def deliver_agent99_telegram_lifecycle( payload: Agent99TelegramLifecycleRequest, ) -> dict[str, Any]: """Send once through the registered AWOOOI SRE route and verify its ack.""" + visual_png = payload.visual.png_bytes() if payload.visual is not None else None response = await get_telegram_gateway().send_agent99_lifecycle_receipt( delivery_id=payload.delivery_id, incident_id=payload.incident_id, state_key=payload.state_key, - text=_format_lifecycle_card(payload), + text=( + _format_lifecycle_caption(payload) + if visual_png is not None + else _format_lifecycle_card(payload) + ), priority="P0" if payload.severity == "critical" else "P1", project_id=payload.project_id, reply_to_message_id=payload.reply_to_message_id, + visual_png=visual_png, + visual_sha256=(payload.visual.sha256 if payload.visual is not None else None), ) result = response.get("result") if isinstance(response, dict) else None message_id = str(result.get("message_id") or "") if isinstance(result, dict) else "" @@ -94,6 +134,13 @@ async def deliver_agent99_telegram_lifecycle( and message_id and delivery_status in {"sent", "sent_reused"} ) + visual_requested = payload.visual is not None + visual_sent = bool( + ok + and visual_requested + and isinstance(response, dict) + and response.get("_awooop_visual_delivery_verified") is True + ) return { "schema_version": "agent99_telegram_lifecycle_receipt_v1", "ok": ok, @@ -109,6 +156,15 @@ async def deliver_agent99_telegram_lifecycle( isinstance(response, dict) and response.get("_awooop_provider_send_performed") is True ), + "visual_requested": visual_requested, + "visual_sent": visual_sent, + "visual_delivery_status": ( + delivery_status if visual_requested else "not_requested" + ), + "visual_sha256": ( + payload.visual.sha256 if payload.visual is not None else None + ), "stores_secret": False, "raw_response_stored": False, + "raw_visual_stored": False, } diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 4a1ae6d0c..7c72746ee 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -150,6 +150,7 @@ _NON_MONITORING_INTERACTION_KEY = "_awoooi_non_monitoring_interaction" _BOT_TOKEN_OVERRIDE_KEY = "_awoooi_bot_token_override" _ACTUAL_BOT_ALIAS_KEY = "_awoooi_actual_bot_alias" _DELIVERY_CONTEXT_KEY = "_awoooi_delivery_context" +_BINARY_UPLOAD_KEY = "_awoooi_binary_upload" _CANONICAL_SRE_DESTINATION = "telegram_chat_alias:awoooi_sre_war_room" _GUARDED_TELEGRAM_BOT_METHODS = frozenset( { @@ -176,6 +177,22 @@ _NON_MONITORING_INTERACTION_KINDS = frozenset( ) +def _telegram_multipart_data(payload: Mapping[str, object]) -> dict[str, str]: + """Serialize Telegram fields without placing binary media in JSON or logs.""" + + data: dict[str, str] = {} + for key, value in payload.items(): + if value is None: + continue + if isinstance(value, bool): + data[key] = "true" if value else "false" + elif isinstance(value, dict | list): + data[key] = json.dumps(value, ensure_ascii=False, separators=(",", ":")) + else: + data[key] = str(value) + return data + + def _telegram_destination_binding(chat_id: object) -> str: """Return a durable, redaction-safe binding for one Telegram destination.""" if isinstance(chat_id, bool) or chat_id is None: @@ -5409,7 +5426,6 @@ class TelegramGateway: # 舊的 timeout=30.0 會讓 getUpdates(timeout=40s) 每次都被 client 先打斷 self._http_client = httpx.AsyncClient( timeout=httpx.Timeout(connect=10.0, read=50.0, write=10.0, pool=10.0), - headers={"Content-Type": "application/json"}, ) await self._security.initialize() @@ -5773,6 +5789,7 @@ class TelegramGateway: identity: dict[str, str], payload: dict, source_envelope_extra: dict[str, object], + method: str = "sendMessage", ) -> dict[str, object]: """Persist a send-once reservation before contacting Telegram.""" from sqlalchemy import text @@ -5830,7 +5847,11 @@ class TelegramGateway: SELECT message_id::text AS message_id, send_status, - provider_message_id + provider_message_id, + ( + source_envelope #>> + '{visual_delivery,requested}' + ) = 'true' AS visual_requested FROM awooop_outbound_message WHERE project_id = :project_id AND run_id = CAST(:run_id AS uuid) @@ -5913,15 +5934,23 @@ class TelegramGateway: payload.get("chat_id") or "" ), message_type=_infer_outbound_message_type( - str(payload.get("text") or ""), + str( + payload.get("text") + or payload.get("caption") + or "" + ), payload, shadow_extra, ), - content=str(payload.get("text") or ""), + content=str( + payload.get("text") + or payload.get("caption") + or "" + ), source_envelope=( _merge_outbound_source_envelope_extra( _outbound_source_envelope( - "sendMessage", + method, payload, ), shadow_extra, @@ -5950,15 +5979,23 @@ class TelegramGateway: channel_type="telegram", channel_chat_id=str(payload.get("chat_id") or ""), message_type=_infer_outbound_message_type( - str(payload.get("text") or ""), + str( + payload.get("text") + or payload.get("caption") + or "" + ), payload, source_envelope_extra, ), - content=str(payload.get("text") or ""), + content=str( + payload.get("text") + or payload.get("caption") + or "" + ), source_envelope=( _merge_outbound_source_envelope_extra( _outbound_source_envelope( - "sendMessage", + method, payload, ), reservation_extra, @@ -6002,6 +6039,9 @@ class TelegramGateway: "run_id": str(delivery_run_id), "message_id": str(existing_row["message_id"]), "provider_message_id": provider_message_id, + "visual_requested": ( + existing_row.get("visual_requested") is True + ), } return { "status": "pending_unknown", @@ -6173,6 +6213,26 @@ class TelegramGateway: dict: API 回應 """ payload = dict(payload) + binary_upload = payload.pop(_BINARY_UPLOAD_KEY, None) + if binary_upload is not None: + valid_upload = bool( + method == "sendPhoto" + and isinstance(binary_upload, Mapping) + and binary_upload.get("field_name") == "photo" + and binary_upload.get("filename") == "agent99-card.png" + and binary_upload.get("content_type") == "image/png" + and isinstance(binary_upload.get("content"), bytes) + and 0 < len(binary_upload["content"]) <= 1024 * 1024 + and binary_upload["content"][:8] == b"\x89PNG\r\n\x1a\n" + and payload.get("photo") == "attach://photo" + ) + if not valid_upload: + return { + "ok": False, + "_awooop_outbound_mirror_acknowledged": False, + "_awooop_delivery_status": "invalid_binary_upload_contract", + "_awooop_provider_send_performed": False, + } actual_bot_alias = str( payload.pop(_ACTUAL_BOT_ALIAS_KEY, "tsenyang_bot") ).strip() @@ -6302,7 +6362,7 @@ class TelegramGateway: controlled_reservation: dict[str, object] | None = None if controlled_apply_result_requested: if ( - method != "sendMessage" + method not in {"sendMessage", "sendPhoto"} or controlled_delivery_identity is None or not isinstance(source_envelope_extra, dict) ): @@ -6321,6 +6381,7 @@ class TelegramGateway: identity=controlled_delivery_identity, payload=payload, source_envelope_extra=source_envelope_extra, + method=method, ) ) reservation_status = str( @@ -6337,6 +6398,9 @@ class TelegramGateway: "_awooop_outbound_mirror_acknowledged": True, "_awooop_delivery_status": "sent_reused", "_awooop_provider_send_performed": False, + "_awooop_visual_delivery_verified": bool( + controlled_reservation.get("visual_requested") is True + ), } if reservation_status == "suppressed": return { @@ -6378,7 +6442,20 @@ class TelegramGateway: }, ) as span: try: - response = await self._http_client.post(url, json=payload) + if binary_upload is not None: + response = await self._http_client.post( + url, + data=_telegram_multipart_data(payload), + files={ + "photo": ( + "agent99-card.png", + binary_upload["content"], + "image/png", + ) + }, + ) + else: + response = await self._http_client.post(url, json=payload) response.raise_for_status() result = response.json() @@ -6440,6 +6517,11 @@ class TelegramGateway: ), } result[_DELIVERY_CONTEXT_KEY] = delivery_context + result["_awooop_visual_delivery_verified"] = bool( + method == "sendPhoto" + and has_provider_message_id + and destination_binding_verified + ) if has_provider_message_id: span.set_attribute( "telegram.message_id", @@ -11612,6 +11694,8 @@ class TelegramGateway: priority: str, project_id: str = "awoooi", reply_to_message_id: int | None = None, + visual_png: bytes | None = None, + visual_sha256: str | None = None, ) -> dict: """Send one structured Agent99 lifecycle update with a durable ack.""" @@ -11636,6 +11720,12 @@ class TelegramGateway: "final" if "recovered" in state_key else "error" ) source_extra["execution_kind"] = "agent99_lifecycle" + source_extra["visual_delivery"] = { + "requested": visual_png is not None, + "media_type": "image/png" if visual_png is not None else None, + "sha256": visual_sha256 if visual_png is not None else None, + "raw_visual_stored": False, + } source_extra["notification_policy"] = { "policy_version": "agent99_lifecycle_send_once_v1", "provider_delivery": "immediate", @@ -11655,10 +11745,8 @@ class TelegramGateway: if isinstance(source_refs, dict): source_refs["automation_run_ids"] = [delivery_id] + method = "sendPhoto" if visual_png is not None else "sendMessage" payload: dict[str, object] = { - "text": text[:4096], - "parse_mode": "HTML", - "disable_web_page_preview": True, _CANONICAL_ROUTE_CONTEXT_KEY: { "product_id": "awoooi", "signal_family": "incident_lifecycle", @@ -11666,6 +11754,28 @@ class TelegramGateway: }, _AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY: source_extra, } + if visual_png is not None: + payload.update( + { + "photo": "attach://photo", + "caption": text[:1024], + "parse_mode": "HTML", + _BINARY_UPLOAD_KEY: { + "field_name": "photo", + "filename": "agent99-card.png", + "content_type": "image/png", + "content": visual_png, + }, + } + ) + else: + payload.update( + { + "text": text[:4096], + "parse_mode": "HTML", + "disable_web_page_preview": True, + } + ) reply_markup = incident_truth_chain_reply_markup( incident_id, project_id=project_id or "awoooi", @@ -11674,7 +11784,7 @@ class TelegramGateway: payload["reply_markup"] = reply_markup if reply_to_message_id is not None: payload["reply_to_message_id"] = reply_to_message_id - return await self._send_request("sendMessage", payload) + return await self._send_request(method, payload) async def send_controlled_apply_result_receipt( self, diff --git a/apps/api/tests/test_agent99_telegram_lifecycle_api.py b/apps/api/tests/test_agent99_telegram_lifecycle_api.py index 1a0996409..a1cce3ec0 100644 --- a/apps/api/tests/test_agent99_telegram_lifecycle_api.py +++ b/apps/api/tests/test_agent99_telegram_lifecycle_api.py @@ -1,7 +1,10 @@ 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") @@ -41,6 +44,25 @@ def payload() -> dict: } +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") @@ -60,6 +82,33 @@ def test_lifecycle_payload_rejects_unbounded_content() -> None: 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: @@ -206,3 +255,182 @@ async def test_lifecycle_service_uses_durable_canonical_sender(monkeypatch) -> N 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") diff --git a/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py b/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py index 7a7be12c5..d640f9ba7 100644 --- a/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py +++ b/apps/api/tests/test_agent99_transport_recovery_deploy_contract.py @@ -433,6 +433,19 @@ def test_agent99_completion_callback_waits_for_durable_same_trace_readback() -> assert 'secretValueLogged = $false' in source +def test_agent99_lifecycle_visual_is_bounded_hashed_and_canonical() -> None: + source = CONTROL.read_text(encoding="utf-8") + + assert 'schema_version = "agent99_telegram_visual_v1"' in source + assert 'generated_by = "agent99_system_drawing_v1"' in source + assert "$visualBytes.Length -gt 1048576" in source + assert "[Security.Cryptography.SHA256]::Create()" in source + assert "[Convert]::ToBase64String($visualBytes)" in source + assert "visual = $visualPayload" in source + assert 'Get-AgentObjectValue $response "visual_sent"' in source + assert "api.telegram.org" not in source + + def test_agent99_selfhealth_ignores_suppressed_lifecycle_attempts() -> None: source = CONTROL.read_text(encoding="utf-8") function = source[source.index("function Test-AgentRecentTelegramDelivery") :] diff --git a/docs/operations/agent99-host112-host111-recovery-readback-2026-07-15.snapshot.json b/docs/operations/agent99-host112-host111-recovery-readback-2026-07-15.snapshot.json index 2dbfd0bc8..3d9104972 100644 --- a/docs/operations/agent99-host112-host111-recovery-readback-2026-07-15.snapshot.json +++ b/docs/operations/agent99-host112-host111-recovery-readback-2026-07-15.snapshot.json @@ -50,10 +50,23 @@ "safe_next_action": "retain incident, suppress duplicate WOL, verify power/network and exact host identity before recovery close" }, "source_change": { + "source_verified_at": "2026-07-15T17:30:53+08:00", "gitea_main_deployed": false, "feature_branch_only": true, "windows_ast_error_count": 0, - "focused_tests_passed": 82, + "host_recovery_focused_tests_passed": 83, + "telegram_and_durable_outbox_tests_passed": 140, + "telegram_visual_delivery": { + "status": "source_verified_not_deployed", + "primary_method": "sendPhoto", + "duplicate_message_created": false, + "send_once_identity_preserved": true, + "max_png_bytes": 1048576, + "fixed_dimensions": "1200x760", + "sha256_required": true, + "legacy_text_reuse_false_green_blocked": true, + "raw_visual_stored": false + }, "github_used": false }, "secret_value_read": false, diff --git a/docs/runbooks/FULL-STACK-COLD-START-SOP.md b/docs/runbooks/FULL-STACK-COLD-START-SOP.md index f5e788b9e..d63b9ce3d 100644 --- a/docs/runbooks/FULL-STACK-COLD-START-SOP.md +++ b/docs/runbooks/FULL-STACK-COLD-START-SOP.md @@ -2745,3 +2745,28 @@ Bonjour `.80` 位址均失聯。Agent99 已自動 Detect 並執行 allowlisted W 機器可讀 incident evidence: `docs/operations/agent99-host112-host111-recovery-readback-2026-07-15.snapshot.json`。 + +### 16.8 2026-07-15 Agent99 Telegram 視覺事件卡 durable delivery + +Windows99 原本已能產生 1200x760 PNG,但 canonical lifecycle ingress 只送 +`sendMessage`,因此 evidence 會正確顯示 `visualSent=false`。修正後,Agent99 對已啟用 +視覺卡的 lifecycle 使用同一 send-once identity 送出單一 `sendPhoto` 主訊息與短 caption; +不得再先送長文字、再補一張圖片造成雙倍噪音。 + +視覺傳輸合約: + +- 只接受 `agent99_system_drawing_v1` 產生的 PNG;固定 `1200x760`、上限 `1 MiB`,並驗證 + PNG header、IHDR dimensions 與 SHA-256。不得接收檔案路徑、raw screenshot path、任意 + media URL、Bot token、raw log 或 secret。 +- PNG 只在記憶體中經 Agent99 lifecycle ingress 轉成 Telegram multipart;AWOOOI outbox、 + LOG、KM 與 API receipt 只保存 hash、media type、delivery state 與 provider message ID, + `raw_visual_stored=false`。 +- `sendPhoto` 必須沿用相同 `delivery_id / incident_id / state_key / run_id`、canonical route、 + reservation、destination binding、provider ack 與 incident reply markup;圖片不是第二條旁路。 +- caption 必須是繁體中文優先、行動裝置可掃讀,且在 escape 前逐欄 bounded,禁止直接從 + 4096 字文字卡粗暴截斷,避免切斷 HTML entity/tag。 +- `visual_sent=true` 只在 provider image delivery 與 destination verifier 都通過時成立。 + 舊版文字訊息即使同 identity 回 `sent_reused`,若 outbox 沒有 visual delivery evidence, + 仍必須回 `visual_sent=false`,不得假綠。 +- 圖片渲染或傳輸失敗時文字 lifecycle 仍可送達,但 terminal 只能是 partial/degraded, + `visualError` 與 `visual_delivery_status` 必須留在 evidence,待同一 incident 後續修復。