feat(telegram): accept signed callback forwards
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 5m59s
E2E Health Check / e2e-health (push) Failing after 32s
CD Pipeline / build-and-deploy (push) Successful in 15m0s
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 0s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Successful in 12s
CD Pipeline / post-deploy-checks (push) Successful in 5m49s
Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 5m59s
E2E Health Check / e2e-health (push) Failing after 32s
CD Pipeline / build-and-deploy (push) Successful in 15m0s
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 0s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Successful in 12s
CD Pipeline / post-deploy-checks (push) Successful in 5m49s
This commit is contained in:
@@ -9,7 +9,10 @@ WS4 Hermes NL 接入:@mention 觸發 12-Agent 自然語言問答。
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Optional
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
|
||||
import structlog
|
||||
from fastapi import APIRouter, Depends, Header, HTTPException, Request
|
||||
@@ -20,9 +23,14 @@ logger = structlog.get_logger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/telegram", tags=["Telegram Webhook"])
|
||||
|
||||
_CALLBACK_FORWARD_MAX_BODY_BYTES = 64 * 1024
|
||||
_CALLBACK_FORWARD_MAX_CLOCK_SKEW_SECONDS = 60
|
||||
_CALLBACK_FORWARD_DEDUP_TTL_SECONDS = 10 * 60
|
||||
_CALLBACK_FORWARD_DEDUP_PREFIX = "telegram:callback_forward:dedup:v1:"
|
||||
|
||||
|
||||
def _verify_secret_token(
|
||||
x_telegram_bot_api_secret_token: Optional[str] = Header(None),
|
||||
x_telegram_bot_api_secret_token: str | None = Header(None),
|
||||
) -> None:
|
||||
"""
|
||||
驗證 Telegram Webhook Secret Token(ADR-094 P0-2 修)
|
||||
@@ -48,6 +56,154 @@ def _verify_secret_token(
|
||||
raise HTTPException(status_code=403, detail="Invalid webhook secret")
|
||||
|
||||
|
||||
def _verify_callback_forward_signature(
|
||||
body: bytes,
|
||||
*,
|
||||
timestamp: str | None,
|
||||
signature: str | None,
|
||||
) -> None:
|
||||
"""Authenticate the host188 forwarder without logging secret material."""
|
||||
token = str(getattr(settings, "OPENCLAW_TG_BOT_TOKEN", "") or "")
|
||||
if not token:
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Telegram callback forwarder authentication is not configured",
|
||||
)
|
||||
if not body or len(body) > _CALLBACK_FORWARD_MAX_BODY_BYTES:
|
||||
raise HTTPException(status_code=413, detail="Invalid callback forward body size")
|
||||
try:
|
||||
forwarded_at = int(str(timestamp or ""))
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=403, detail="Invalid callback forward signature") from exc
|
||||
if abs(int(time.time()) - forwarded_at) > _CALLBACK_FORWARD_MAX_CLOCK_SKEW_SECONDS:
|
||||
raise HTTPException(status_code=403, detail="Expired callback forward signature")
|
||||
|
||||
provided = str(signature or "").strip().lower()
|
||||
signed = str(forwarded_at).encode("ascii") + b"." + body
|
||||
expected = "sha256=" + hmac.new(
|
||||
token.encode("utf-8"),
|
||||
signed,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
if not hmac.compare_digest(provided, expected):
|
||||
raise HTTPException(status_code=403, detail="Invalid callback forward signature")
|
||||
|
||||
|
||||
@router.post("/callback-forward")
|
||||
async def telegram_callback_forward(
|
||||
request: Request,
|
||||
x_telegram_forward_timestamp: str | None = Header(
|
||||
None,
|
||||
alias="X-Telegram-Forward-Timestamp",
|
||||
),
|
||||
x_telegram_forward_signature: str | None = Header(
|
||||
None,
|
||||
alias="X-Telegram-Forward-Signature",
|
||||
),
|
||||
) -> dict:
|
||||
"""Receive one HMAC-authenticated callback from the sole polling owner."""
|
||||
body_bytes = await request.body()
|
||||
_verify_callback_forward_signature(
|
||||
body_bytes,
|
||||
timestamp=x_telegram_forward_timestamp,
|
||||
signature=x_telegram_forward_signature,
|
||||
)
|
||||
try:
|
||||
body = json.loads(body_bytes)
|
||||
except (TypeError, ValueError, json.JSONDecodeError) as exc:
|
||||
raise HTTPException(status_code=400, detail="Invalid callback forward JSON") from exc
|
||||
|
||||
if not isinstance(body, dict) or set(body) - {"update_id", "callback_query"}:
|
||||
raise HTTPException(status_code=400, detail="Invalid callback forward envelope")
|
||||
update_id = body.get("update_id")
|
||||
callback = body.get("callback_query")
|
||||
if not isinstance(update_id, int) or not isinstance(callback, dict):
|
||||
raise HTTPException(status_code=400, detail="Invalid callback forward envelope")
|
||||
|
||||
callback_query_id = str(callback.get("id") or "")
|
||||
callback_data = str(callback.get("data") or "")
|
||||
user_id = (callback.get("from") or {}).get("id")
|
||||
message = callback.get("message") or {}
|
||||
chat_id = (message.get("chat") or {}).get("id")
|
||||
message_id = message.get("message_id")
|
||||
if not all(
|
||||
[callback_query_id, callback_data, user_id, chat_id, message_id]
|
||||
):
|
||||
raise HTTPException(status_code=400, detail="Incomplete callback forward envelope")
|
||||
|
||||
from src.services.telegram_button_registry import (
|
||||
resolve_callback_action_contract,
|
||||
)
|
||||
|
||||
contract = resolve_callback_action_contract(callback_data)
|
||||
if (
|
||||
contract is None
|
||||
or contract.callback_format != "info"
|
||||
or contract.risk != "low"
|
||||
or contract.state != "active"
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Callback action is not forwardable")
|
||||
|
||||
dedup_id = hashlib.sha256(callback_query_id.encode("utf-8")).hexdigest()[:24]
|
||||
dedup_key = f"{_CALLBACK_FORWARD_DEDUP_PREFIX}{update_id}:{dedup_id}"
|
||||
try:
|
||||
from src.core.redis_client import get_redis
|
||||
|
||||
redis = get_redis()
|
||||
claimed = await redis.set(
|
||||
dedup_key,
|
||||
"processing",
|
||||
nx=True,
|
||||
ex=_CALLBACK_FORWARD_DEDUP_TTL_SECONDS,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.error(
|
||||
"telegram_callback_forward_dedup_unavailable",
|
||||
error_type=type(exc).__name__,
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=503,
|
||||
detail="Callback forward deduplication unavailable",
|
||||
) from exc
|
||||
if not claimed:
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "duplicate_ignored",
|
||||
"update_id": update_id,
|
||||
}
|
||||
|
||||
from src.services.telegram_gateway import get_telegram_gateway
|
||||
|
||||
try:
|
||||
gateway = get_telegram_gateway()
|
||||
await gateway._handle_callback_query(
|
||||
update_id,
|
||||
callback,
|
||||
ingress_source="openclaw_188_hmac_forwarder",
|
||||
)
|
||||
await redis.setex(
|
||||
dedup_key,
|
||||
_CALLBACK_FORWARD_DEDUP_TTL_SECONDS,
|
||||
"processed",
|
||||
)
|
||||
except Exception:
|
||||
await redis.delete(dedup_key)
|
||||
raise
|
||||
|
||||
logger.info(
|
||||
"telegram_callback_forward_processed",
|
||||
update_id=update_id,
|
||||
action=contract.action,
|
||||
callback_payload_logged=False,
|
||||
)
|
||||
return {
|
||||
"ok": True,
|
||||
"status": "processed",
|
||||
"update_id": update_id,
|
||||
"action": contract.action,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/webhook", dependencies=[Depends(_verify_secret_token)])
|
||||
async def telegram_webhook(request: Request) -> dict:
|
||||
"""
|
||||
@@ -107,7 +263,11 @@ async def telegram_webhook(request: Request) -> dict:
|
||||
if group_id and member_user_id:
|
||||
try:
|
||||
from src.core.redis_client import get_redis # type: ignore[import]
|
||||
from src.hermes.approvers import sync_member_joined, sync_member_left # type: ignore[import]
|
||||
from src.hermes.approvers import ( # type: ignore[import]
|
||||
sync_member_joined,
|
||||
sync_member_left,
|
||||
)
|
||||
|
||||
redis = get_redis()
|
||||
if status in ("member", "administrator", "creator"):
|
||||
await sync_member_joined(redis, group_id, member_user_id)
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import time
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
@@ -369,6 +373,144 @@ class _CanonicalWebhookGateway:
|
||||
})
|
||||
|
||||
|
||||
class _CallbackForwardRequest:
|
||||
def __init__(self, body: bytes) -> None:
|
||||
self._body = body
|
||||
|
||||
async def body(self) -> bytes:
|
||||
return self._body
|
||||
|
||||
|
||||
class _CallbackForwardRedis:
|
||||
def __init__(self) -> None:
|
||||
self.values: dict[str, str] = {}
|
||||
|
||||
async def set(self, key: str, value: str, **_kwargs) -> bool:
|
||||
if key in self.values:
|
||||
return False
|
||||
self.values[key] = value
|
||||
return True
|
||||
|
||||
async def setex(self, key: str, _ttl: int, value: str) -> None:
|
||||
self.values[key] = value
|
||||
|
||||
async def delete(self, key: str) -> None:
|
||||
self.values.pop(key, None)
|
||||
|
||||
|
||||
def _signed_callback_forward_body(token: str) -> tuple[bytes, str, str]:
|
||||
body = json.dumps(
|
||||
{
|
||||
"callback_query": {
|
||||
"data": "detail:INC-20260716-TEST",
|
||||
"from": {"id": 7, "username": "operator"},
|
||||
"id": "callback-forward-77",
|
||||
"message": {
|
||||
"chat": {"id": -1001},
|
||||
"message_id": 9,
|
||||
"text": "incident",
|
||||
},
|
||||
},
|
||||
"update_id": 77,
|
||||
},
|
||||
separators=(",", ":"),
|
||||
sort_keys=True,
|
||||
).encode("utf-8")
|
||||
timestamp = str(int(time.time()))
|
||||
signature = "sha256=" + hmac.new(
|
||||
token.encode("utf-8"),
|
||||
timestamp.encode("ascii") + b"." + body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
return body, timestamp, signature
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hmac_callback_forward_processes_only_registered_info_action(
|
||||
monkeypatch,
|
||||
) -> None:
|
||||
token = "test-forwarder-token"
|
||||
body, timestamp, signature = _signed_callback_forward_body(token)
|
||||
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",
|
||||
}
|
||||
assert gateway.receipts[0]["ingress_source"] == (
|
||||
"openclaw_188_hmac_forwarder"
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hmac_callback_forward_rejects_invalid_signature(monkeypatch) -> None:
|
||||
token = "test-forwarder-token"
|
||||
body, timestamp, _signature = _signed_callback_forward_body(token)
|
||||
monkeypatch.setattr(
|
||||
telegram_webhook_api,
|
||||
"settings",
|
||||
SimpleNamespace(OPENCLAW_TG_BOT_TOKEN=token),
|
||||
)
|
||||
|
||||
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="sha256=invalid",
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_hmac_callback_forward_rejects_write_action(monkeypatch) -> None:
|
||||
token = "test-forwarder-token"
|
||||
body, timestamp, _signature = _signed_callback_forward_body(token)
|
||||
unsafe_body = body.replace(
|
||||
b"detail:INC-20260716-TEST",
|
||||
b"approve:APR-1:1:nonce",
|
||||
)
|
||||
signature = "sha256=" + hmac.new(
|
||||
token.encode("utf-8"),
|
||||
timestamp.encode("ascii") + b"." + unsafe_body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
monkeypatch.setattr(
|
||||
telegram_webhook_api,
|
||||
"settings",
|
||||
SimpleNamespace(OPENCLAW_TG_BOT_TOKEN=token),
|
||||
)
|
||||
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
await telegram_webhook_api.telegram_callback_forward(
|
||||
_CallbackForwardRequest(unsafe_body),
|
||||
x_telegram_forward_timestamp=timestamp,
|
||||
x_telegram_forward_signature=signature,
|
||||
)
|
||||
|
||||
assert exc_info.value.status_code == 403
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_canonical_webhook_forwards_callback_to_gateway_handler(monkeypatch) -> None:
|
||||
gateway = _CanonicalWebhookGateway()
|
||||
|
||||
Reference in New Issue
Block a user