fix(telegram): persist controlled callback action receipts

This commit is contained in:
Your Name
2026-07-18 16:55:43 +08:00
parent 79489635d8
commit 7d1968835a
4 changed files with 427 additions and 15 deletions

View File

@@ -20,7 +20,13 @@ from src.services.telegram_button_registry import (
resolve_callback_action_contract,
telegram_button_registry_snapshot,
)
from src.services.telegram_callback_correlation import (
CONTROLLED_CALLBACK_CORRELATION_SCHEMA,
controlled_callback_correlation_fingerprint,
normalize_controlled_callback_correlation,
)
from src.services.telegram_gateway import (
CALLBACK_CONTROLLED_ACTION_RECEIPT_KEY,
CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS,
TelegramGateway,
filter_telegram_reply_markup,
@@ -82,13 +88,13 @@ def test_callback_ingress_receipt_has_five_minute_freshness_contract() -> None:
class _SharedReceiptRedis:
def __init__(self) -> None:
self.value: str | None = None
self.values: dict[str, str] = {}
async def setex(self, _key: str, _ttl: int, value: str) -> None:
self.value = value
async def setex(self, key: str, _ttl: int, value: str) -> None:
self.values[key] = value
async def get(self, _key: str) -> str | None:
return self.value
async def get(self, key: str) -> str | None:
return self.values.get(key)
@pytest.mark.asyncio
@@ -356,8 +362,9 @@ class _CallbackRequest:
class _CanonicalWebhookGateway:
def __init__(self) -> None:
def __init__(self, *, success: bool = True) -> None:
self.receipts: list[dict] = []
self.success = success
async def _handle_callback_query(
self,
@@ -365,12 +372,19 @@ class _CanonicalWebhookGateway:
callback_query: dict,
*,
ingress_source: str,
) -> None:
ingress_correlation: dict | None = None,
) -> dict:
self.receipts.append({
"update_id": update_id,
"callback_query": callback_query,
"ingress_source": ingress_source,
"ingress_correlation": ingress_correlation,
})
return {
"success": self.success,
"action": "detail",
"controlled_action_receipt_durable": bool(ingress_correlation),
}
class _CallbackForwardRequest:
@@ -398,9 +412,12 @@ class _CallbackForwardRedis:
self.values.pop(key, None)
def _signed_callback_forward_body(token: str) -> tuple[bytes, str, str]:
body = json.dumps(
{
def _signed_callback_forward_body(
token: str,
*,
controlled_correlation: dict | None = None,
) -> tuple[bytes, str, str]:
payload = {
"callback_query": {
"data": "detail:INC-20260716-TEST",
"from": {"id": 7, "username": "operator"},
@@ -412,7 +429,11 @@ def _signed_callback_forward_body(token: str) -> tuple[bytes, str, str]:
},
},
"update_id": 77,
},
}
if controlled_correlation is not None:
payload["controlled_correlation"] = controlled_correlation
body = json.dumps(
payload,
separators=(",", ":"),
sort_keys=True,
).encode("utf-8")
@@ -456,12 +477,146 @@ async def test_hmac_callback_forward_processes_only_registered_info_action(
"status": "processed",
"update_id": 77,
"action": "detail",
"controlled_receipt": False,
}
assert gateway.receipts[0]["ingress_source"] == (
"openclaw_188_hmac_forwarder"
)
def _controlled_correlation() -> dict[str, str]:
return {
"schema_version": CONTROLLED_CALLBACK_CORRELATION_SCHEMA,
"trace_id": "trace:host188:001",
"run_id": "run:host188:001",
"work_item_id": "AIA-SRE-017",
}
def test_controlled_callback_correlation_is_exact_and_stably_fingerprinted() -> None:
correlation = _controlled_correlation()
assert normalize_controlled_callback_correlation(correlation) == correlation
assert len(controlled_callback_correlation_fingerprint(correlation)) == 64
with pytest.raises(ValueError, match="shape"):
normalize_controlled_callback_correlation({**correlation, "extra": "no"})
with pytest.raises(ValueError, match="run_id"):
normalize_controlled_callback_correlation({**correlation, "run_id": "unsafe id"})
@pytest.mark.asyncio
async def test_hmac_callback_forward_carries_controlled_receipt_without_raw_ids(
monkeypatch,
) -> None:
token = "test-forwarder-token"
correlation = _controlled_correlation()
body, timestamp, signature = _signed_callback_forward_body(
token,
controlled_correlation=correlation,
)
gateway = _CanonicalWebhookGateway()
redis = _CallbackForwardRedis()
monkeypatch.setattr(
telegram_webhook_api,
"settings",
SimpleNamespace(OPENCLAW_TG_BOT_TOKEN=token),
)
monkeypatch.setattr(
telegram_gateway_module,
"get_telegram_gateway",
lambda: gateway,
)
monkeypatch.setattr("src.core.redis_client.get_redis", lambda: redis)
result = await telegram_webhook_api.telegram_callback_forward(
_CallbackForwardRequest(body),
x_telegram_forward_timestamp=timestamp,
x_telegram_forward_signature=signature,
)
assert result == {
"ok": True,
"status": "processed",
"update_id": 77,
"action": "detail",
"controlled_receipt": True,
}
assert gateway.receipts[0]["ingress_correlation"] == correlation
assert correlation["run_id"] not in json.dumps(result)
@pytest.mark.asyncio
async def test_hmac_callback_forward_does_not_finalize_failed_action(monkeypatch) -> None:
token = "test-forwarder-token"
body, timestamp, signature = _signed_callback_forward_body(token)
gateway = _CanonicalWebhookGateway(success=False)
redis = _CallbackForwardRedis()
monkeypatch.setattr(
telegram_webhook_api,
"settings",
SimpleNamespace(OPENCLAW_TG_BOT_TOKEN=token),
)
monkeypatch.setattr(
telegram_gateway_module,
"get_telegram_gateway",
lambda: gateway,
)
monkeypatch.setattr("src.core.redis_client.get_redis", lambda: redis)
with pytest.raises(HTTPException) as exc_info:
await telegram_webhook_api.telegram_callback_forward(
_CallbackForwardRequest(body),
x_telegram_forward_timestamp=timestamp,
x_telegram_forward_signature=signature,
)
assert exc_info.value.status_code == 502
assert redis.values == {}
@pytest.mark.asyncio
async def test_successful_controlled_action_receipt_is_durable_and_redacted(
monkeypatch,
) -> None:
redis = _SharedReceiptRedis()
monkeypatch.setattr(telegram_gateway_module, "get_redis", lambda: redis)
writer = _InfoCallbackGateway()
reader = _ConfiguredGateway()
correlation = _controlled_correlation()
result = await writer._handle_callback_query(
42,
{
"id": "callback-controlled-1",
"data": "detail:INC-20260716-TEST",
"from": {"id": 7, "username": "operator"},
"message": {
"message_id": 99,
"text": "incident",
"chat": {"id": -1001},
},
},
ingress_source="openclaw_188_hmac_forwarder",
ingress_correlation=correlation,
)
assert result["controlled_action_receipt_durable"] is True
assert CALLBACK_CONTROLLED_ACTION_RECEIPT_KEY in redis.values
assert await reader.refresh_callback_ingress_receipt() is True
receipt = reader.callback_ingress_status()["controlled_action_receipt"]
assert receipt["durable"] is True
assert receipt["fresh"] is True
assert receipt["source"] == "openclaw_188_hmac_forwarder"
assert receipt["action"] == "detail"
assert receipt["correlation_fingerprint"] == (
controlled_callback_correlation_fingerprint(correlation)
)
serialized = json.dumps(receipt)
assert correlation["trace_id"] not in serialized
assert correlation["run_id"] not in serialized
assert correlation["work_item_id"] not in serialized
@pytest.mark.asyncio
async def test_hmac_callback_forward_rejects_invalid_signature(monkeypatch) -> None:
token = "test-forwarder-token"