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 後續修復。