fix(agent99): deliver visual lifecycle cards

This commit is contained in:
ogt
2026-07-15 17:31:18 +08:00
parent b9ee3c5cbb
commit d471b3cfe8
9 changed files with 553 additions and 19 deletions

View File

@@ -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("<b>") == caption.count("</b>") == 2
assert caption.count("<code>") == caption.count("</code>") == 1
assert "&amp" not in caption.replace("&amp;", "")
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 "<b>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")

View File

@@ -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") :]