diff --git a/agent99-control-plane.ps1 b/agent99-control-plane.ps1
index 53afecfff..3226cbdc5 100644
--- a/agent99-control-plane.ps1
+++ b/agent99-control-plane.ps1
@@ -875,15 +875,26 @@ function Get-AgentIncidentCardModel {
$targetName = [string](Get-AgentObjectValue $Data "target" (Get-AgentObjectValue $Data "alertService" ""))
$alertId = [string](Get-AgentObjectValue $Data "alertId" "")
$correlationKey = [string](Get-AgentObjectValue $Data "correlationKey" "")
- $runKey = [string](Get-AgentObjectValue $Data "runId" (Get-AgentObjectValue $Data "automationRunId" (Get-AgentObjectValue $Data "id" "")))
+ $runKey = [string](Get-AgentObjectValue $Data "runId" (Get-AgentObjectValue $Data "run_id" (Get-AgentObjectValue $Data "automationRunId" (Get-AgentObjectValue $Data "automation_run_id" (Get-AgentObjectValue $Data "id" "")))))
$problem = if ($Data -and $Data.PSObject.Properties["problem"]) { $Data.problem } else { $null }
$outcome = if ($Data -and $Data.PSObject.Properties["outcome"]) { $Data.outcome } else { $null }
+ $identity = if ($Data -and $Data.PSObject.Properties["identity"]) { $Data.identity } else { $null }
$problemKey = [string](Get-AgentObjectValue $problem "key" "")
$outcomeState = [string](Get-AgentObjectValue $outcome "state" (Get-AgentObjectValue $Data "outcomeState" ""))
$verifierPassed = Convert-AgentBool (Get-AgentObjectValue $outcome "verifierPassed" (Get-AgentObjectValue $Data "verifierPassed" $false))
$sourceResolved = Convert-AgentBool (Get-AgentObjectValue $outcome "sourceEventResolved" (Get-AgentObjectValue $Data "sourceEventResolved" $false))
$executionOk = Convert-AgentBool (Get-AgentObjectValue $Data "executionOk" (Get-AgentObjectValue $Data "ok" $false))
$postVerifyOk = Convert-AgentBool (Get-AgentObjectValue $Data "postVerifyOk" $false)
+ $traceKey = [string](Get-AgentObjectValue $Data "traceId" (Get-AgentObjectValue $Data "trace_id" (Get-AgentObjectValue $outcome "traceId" (Get-AgentObjectValue $outcome "trace_id" (Get-AgentObjectValue $identity "trace_id" "")))))
+ $workItemKey = [string](Get-AgentObjectValue $Data "workItemId" (Get-AgentObjectValue $Data "work_item_id" (Get-AgentObjectValue $outcome "workItemId" (Get-AgentObjectValue $outcome "work_item_id" (Get-AgentObjectValue $identity "work_item_id" "")))))
+ $verifierName = [string](Get-AgentObjectValue $Data "verifierName" (Get-AgentObjectValue $Data "verifier_name" (Get-AgentObjectValue $outcome "verifierName" (Get-AgentObjectValue $outcome "verifier_name" ""))))
+ $executorName = [string](Get-AgentObjectValue $Data "executorName" (Get-AgentObjectValue $Data "executor_name" (Get-AgentObjectValue $Data "executor" "")))
+ if (-not $executorName -and $modeName) {
+ $executorName = "Agent99:$modeName"
+ } elseif (-not $executorName -and $identity) {
+ $routeId = [string](Get-AgentObjectValue $identity "route_id" "")
+ if ($routeId) { $executorName = "Agent99:$routeId" }
+ }
$family = switch -Regex ($EventType) {
"^performance_|^load_shed_" { "host_performance"; break }
@@ -937,6 +948,68 @@ function Get-AgentIncidentCardModel {
$lifecycle = if ($executionOk -and ($postVerifyOk -or $verifierPassed)) { "recovered" } else { "verifying" }
}
+ # Project only evidence already present in this Agent99 event. Missing stage
+ # receipts remain PENDING instead of being inferred green from card delivery.
+ $allowedLifecycleStageStates = @("pending", "running", "passed", "failed", "not_applicable")
+ $receivedStatus = "passed"
+ $checkStatus = "pending"
+ $applyStatus = "pending"
+ $verifierStatus = "pending"
+ $closureStatus = "pending"
+ $outcomeChecks = if ($outcome -and $outcome.PSObject.Properties["checks"]) { @($outcome.checks) } else { @() }
+ if ($outcomeChecks.Count -gt 0) {
+ $requiredCheckFailures = @(
+ $outcomeChecks | Where-Object {
+ (Convert-AgentBool (Get-AgentObjectValue $_ "required" $true)) -and
+ -not (Convert-AgentBool (Get-AgentObjectValue $_ "passed" $false))
+ }
+ )
+ $checkStatus = if ($requiredCheckFailures.Count -gt 0) { "failed" } else { "passed" }
+ }
+
+ $controlledApplyPresent = [bool]($Data -and $Data.PSObject.Properties["controlledApply"])
+ $controlledApply = [bool]($controlledApplyPresent -and (Convert-AgentBool $Data.controlledApply))
+ $exitCodePresent = [bool]($Data -and $Data.PSObject.Properties["exitCode"])
+ $executionOkPresent = [bool]($Data -and ($Data.PSObject.Properties["executionOk"] -or $Data.PSObject.Properties["ok"]))
+ if ($controlledApplyPresent -and -not $controlledApply) {
+ $applyStatus = "not_applicable"
+ } elseif ($controlledApply -and $outcomeState -eq "candidate_ready") {
+ $applyStatus = "pending"
+ } elseif ($controlledApply -and $exitCodePresent) {
+ $applyStatus = if ([int]$Data.exitCode -eq 0) { "passed" } else { "failed" }
+ } elseif ($controlledApply -and $executionOkPresent) {
+ $applyStatus = if ($executionOk) { "passed" } else { "failed" }
+ }
+
+ if ($verifierPassed -or $postVerifyOk) {
+ $verifierStatus = "passed"
+ } elseif ($outcomeState -in @("failed", "blocked", "degraded")) {
+ $verifierStatus = "failed"
+ } elseif ($lifecycle -eq "blocked") {
+ $verifierStatus = "failed"
+ } elseif ($lifecycle -eq "verifying") {
+ $verifierStatus = "running"
+ }
+
+ if (($outcomeState -eq "resolved" -and $sourceResolved) -or $lifecycle -eq "recovered") {
+ $closureStatus = "passed"
+ } elseif ($outcomeState -in @("failed", "blocked", "degraded") -or $lifecycle -eq "blocked") {
+ $closureStatus = "failed"
+ } elseif ($lifecycle -in @("remediating", "verifying")) {
+ $closureStatus = "running"
+ }
+
+ $explicitReceivedStatus = [string](Get-AgentObjectValue $Data "receivedStatus" (Get-AgentObjectValue $Data "received_status" ""))
+ $explicitCheckStatus = [string](Get-AgentObjectValue $Data "checkStatus" (Get-AgentObjectValue $Data "check_status" ""))
+ $explicitApplyStatus = [string](Get-AgentObjectValue $Data "applyStatus" (Get-AgentObjectValue $Data "apply_status" ""))
+ $explicitVerifierStatus = [string](Get-AgentObjectValue $Data "verifierStatus" (Get-AgentObjectValue $Data "verifier_status" ""))
+ $explicitClosureStatus = [string](Get-AgentObjectValue $Data "closureStatus" (Get-AgentObjectValue $Data "closure_status" ""))
+ if ($explicitReceivedStatus -in $allowedLifecycleStageStates) { $receivedStatus = $explicitReceivedStatus }
+ if ($explicitCheckStatus -in $allowedLifecycleStageStates) { $checkStatus = $explicitCheckStatus }
+ if ($explicitApplyStatus -in $allowedLifecycleStageStates) { $applyStatus = $explicitApplyStatus }
+ if ($explicitVerifierStatus -in $allowedLifecycleStageStates) { $verifierStatus = $explicitVerifierStatus }
+ if ($explicitClosureStatus -in $allowedLifecycleStageStates) { $closureStatus = $explicitClosureStatus }
+
$lifecycleLabel = switch ($lifecycle) {
"detected" { "已偵測 / DETECTED" }
"investigating" { "調查中 / INVESTIGATING" }
@@ -1174,6 +1247,15 @@ function Get-AgentIncidentCardModel {
lifecycleLabel = $lifecycleLabel
stateKey = "$lifecycle|$Severity"
runKey = $runKey
+ traceKey = $traceKey
+ workItemKey = $workItemKey
+ executorName = if ($executorName) { Limit-AgentTextLine $executorName 150 } else { "" }
+ verifierName = if ($verifierName) { Limit-AgentTextLine $verifierName 150 } else { "" }
+ receivedStatus = $receivedStatus
+ checkStatus = $checkStatus
+ applyStatus = $applyStatus
+ verifierStatus = $verifierStatus
+ closureStatus = $closureStatus
title = Limit-AgentTextLine $title 96
target = Limit-AgentTextLine $target 96
impact = Limit-AgentTextLine $impact 240
@@ -1912,7 +1994,24 @@ function Send-AgentTelegram {
$attempt.visualError = "card_image_render_failed"
}
- $deliverySource = "$($card.incidentId)|$($card.fingerprint)|$($card.stateKey)|$($card.runKey)"
+ # A stage receipt transition is a new visible lifecycle state even when the
+ # coarse lifecycle label is unchanged. Include exact stage/identity fields
+ # so send-once dedup cannot suppress Check/Apply/Verifier/Closure progress.
+ $deliverySource = @(
+ $card.incidentId,
+ $card.fingerprint,
+ $card.stateKey,
+ $card.runKey,
+ $card.traceKey,
+ $card.workItemKey,
+ $card.receivedStatus,
+ $card.checkStatus,
+ $card.applyStatus,
+ $card.verifierStatus,
+ $card.closureStatus,
+ $card.executorName,
+ $card.verifierName
+ ) -join "|"
$deliveryHashBytes = [Security.Cryptography.SHA256]::Create().ComputeHash([Text.Encoding]::UTF8.GetBytes($deliverySource))
$deliveryHash = [BitConverter]::ToString($deliveryHashBytes).Replace("-", "").ToLowerInvariant()
$deliveryId = "agent99-lifecycle-$($deliveryHash.Substring(0, 40))"
@@ -1928,6 +2027,15 @@ function Send-AgentTelegram {
lifecycle = if ($card.lifecycle -in @("detected", "investigating", "remediating", "verifying", "recovered", "blocked")) { [string]$card.lifecycle } else { "unknown" }
state_key = [string]$card.stateKey
run_id = if ($card.runKey) { [string]$card.runKey } else { $null }
+ trace_id = if ($card.traceKey) { [string]$card.traceKey } else { $null }
+ work_item_id = if ($card.workItemKey) { [string]$card.workItemKey } else { $null }
+ executor_name = if ($card.executorName) { [string]$card.executorName } else { $null }
+ verifier_name = if ($card.verifierName) { [string]$card.verifierName } else { $null }
+ received_status = [string]$card.receivedStatus
+ check_status = [string]$card.checkStatus
+ apply_status = [string]$card.applyStatus
+ verifier_status = [string]$card.verifierStatus
+ closure_status = [string]$card.closureStatus
title = [string]$card.title
target = [string]$card.target
impact = [string]$card.impact
diff --git a/apps/api/src/api/v1/telegram.py b/apps/api/src/api/v1/telegram.py
index f83ed62f6..019f98ce4 100644
--- a/apps/api/src/api/v1/telegram.py
+++ b/apps/api/src/api/v1/telegram.py
@@ -9,7 +9,7 @@ Phase 5.5: Long Polling 重構 (內網修復)
- 新: Long Polling 模式 (主動輪詢) - 適用內網環境
Endpoints:
-- POST /api/v1/telegram/webhook - [已棄用] 接收 Telegram Bot Update
+- POST /api/v1/telegram/webhook - canonical owner: api.v1.telegram_webhook
- POST /api/v1/telegram/test-push - 測試推送 (僅開發模式)
- GET /api/v1/telegram/health - Gateway 健康檢查
@@ -35,6 +35,7 @@ from src.services.security_interceptor import (
NonceReplayError,
UserNotWhitelistedError,
)
+from src.services.telegram_button_registry import telegram_button_registry_snapshot
from src.services.telegram_gateway import (
TelegramGatewayError,
_telegram_send_delivery_succeeded,
@@ -268,17 +269,15 @@ async def _sync_telegram_rejection(approval_id: str) -> bool:
# Endpoints
# =============================================================================
-@router.post(
- "/webhook",
- summary="[已棄用] Telegram Bot Webhook",
- description="⚠️ 已棄用:內網環境請使用 Long Polling 模式。此端點保留供外網環境或測試使用。",
- deprecated=True,
-)
async def telegram_webhook(
update: TelegramUpdate,
) -> dict:
"""
- 接收 Telegram Bot Update
+ Legacy callback implementation retained for direct compatibility tests.
+
+ This function is deliberately not registered as an API route. The sole
+ production owner of ``/api/v1/telegram/webhook`` is
+ ``src.api.v1.telegram_webhook``.
處理流程:
1. 驗證 Update 類型 (僅處理 callback_query)
@@ -563,13 +562,26 @@ async def test_push(
summary="Telegram Gateway 健康檢查",
)
async def telegram_health() -> dict:
- """Telegram Gateway 健康狀態 (含 Long Polling 狀態)"""
+ """Telegram Gateway health with callback transport truth."""
gateway = get_telegram_gateway()
+ ingress = gateway.callback_ingress_status()
+ configured = bool(settings.OPENCLAW_TG_BOT_TOKEN and settings.SRE_GROUP_CHAT_ID)
+ interactive_ready = bool(ingress.get("interactive_buttons_ready") is True)
return {
- "status": "configured" if settings.OPENCLAW_TG_BOT_TOKEN else "not_configured",
- "mode": "long_polling", # Phase 5.5: 已從 webhook 切換至 long_polling
- "polling_active": gateway._polling_active,
+ "status": (
+ "ready"
+ if interactive_ready
+ else "degraded_callback_ingress_unverified"
+ if configured
+ else "not_configured"
+ ),
+ "configuration_status": "configured" if configured else "not_configured",
+ "mode": ingress["mode"],
+ "polling_active": ingress["polling_active"],
+ "interactive_buttons_ready": interactive_ready,
+ "callback_ingress": ingress,
+ "button_registry": telegram_button_registry_snapshot(),
"bot_token_set": bool(settings.OPENCLAW_TG_BOT_TOKEN),
"chat_id_set": bool(settings.SRE_GROUP_CHAT_ID),
"sre_group_chat_id_set": bool(settings.SRE_GROUP_CHAT_ID),
diff --git a/apps/api/src/api/v1/telegram_webhook.py b/apps/api/src/api/v1/telegram_webhook.py
index fbdf8bb84..4f6392fae 100644
--- a/apps/api/src/api/v1/telegram_webhook.py
+++ b/apps/api/src/api/v1/telegram_webhook.py
@@ -32,6 +32,12 @@ def _verify_secret_token(
"""
expected = getattr(settings, "TELEGRAM_WEBHOOK_SECRET", "")
if not expected:
+ if settings.ENVIRONMENT == "prod":
+ logger.error("telegram_webhook_secret_not_configured_fail_closed")
+ raise HTTPException(
+ status_code=503,
+ detail="Telegram webhook secret is not configured",
+ )
# dev 環境:未配置 secret → 跳過驗證
return
if x_telegram_bot_api_secret_token != expected:
@@ -47,7 +53,7 @@ async def telegram_webhook(request: Request) -> dict:
"""
接收 Telegram Bot API webhook update。
- 目前:記錄 update 類型 + 回傳 200 OK。
+ Callback Query:交給唯一 TelegramGateway handler,完成 mirror、驗證與 action。
WS4 Hermes NL:在此呼叫 Hermes NL router 處理自然語言指令。
Telegram Bot API 要求此端點在 1 秒內回傳 200,耗時操作須非同步排程。
@@ -73,7 +79,6 @@ async def telegram_webhook(request: Request) -> dict:
if update_type == "callback_query":
callback = body.get("callback_query", {}) or {}
- message = callback.get("message", {}) or {}
user = callback.get("from", {}) or {}
callback_query_id = callback.get("id")
callback_data = callback.get("data")
@@ -82,17 +87,11 @@ async def telegram_webhook(request: Request) -> dict:
from src.services.telegram_gateway import get_telegram_gateway
gateway = get_telegram_gateway()
- mirror_callback = getattr(gateway, "mirror_callback_query_received", None)
- if callable(mirror_callback):
- await mirror_callback(
- update_id=body.get("update_id"),
- callback_query_id=callback_query_id,
- callback_data=callback_data,
- user_id=user_id,
- username=user.get("username") or user.get("first_name") or str(user_id),
- message_id=message.get("message_id"),
- chat_id=(message.get("chat") or {}).get("id"),
- )
+ await gateway._handle_callback_query(
+ body.get("update_id"),
+ callback,
+ ingress_source="canonical_webhook",
+ )
# WS5: chat_member 同步 Approvers 白名單(ADR-093)
if update_type in ("chat_member", "my_chat_member") or (
diff --git a/apps/api/src/models/agent99_completion.py b/apps/api/src/models/agent99_completion.py
index ac8244215..98bd3eb09 100644
--- a/apps/api/src/models/agent99_completion.py
+++ b/apps/api/src/models/agent99_completion.py
@@ -12,6 +12,10 @@ from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator
+Agent99LifecycleStageStatus = Literal[
+ "pending", "running", "passed", "failed", "not_applicable"
+]
+
class Agent99TelegramReceipt(BaseModel):
model_config = ConfigDict(extra="forbid", str_strip_whitespace=True)
@@ -180,6 +184,15 @@ class Agent99TelegramLifecycleRequest(BaseModel):
]
state_key: str = Field(min_length=3, max_length=96)
run_id: str | None = Field(default=None, max_length=180)
+ trace_id: str | None = Field(default=None, max_length=180)
+ work_item_id: str | None = Field(default=None, max_length=180)
+ executor_name: str | None = Field(default=None, max_length=160)
+ verifier_name: str | None = Field(default=None, max_length=160)
+ received_status: Agent99LifecycleStageStatus | None = None
+ check_status: Agent99LifecycleStageStatus | None = None
+ apply_status: Agent99LifecycleStageStatus | None = None
+ verifier_status: Agent99LifecycleStageStatus | None = None
+ closure_status: Agent99LifecycleStageStatus | None = None
title: str = Field(min_length=1, max_length=120)
target: str = Field(min_length=1, max_length=120)
impact: str = Field(min_length=1, max_length=300)
diff --git a/apps/api/src/services/agent99_telegram_lifecycle.py b/apps/api/src/services/agent99_telegram_lifecycle.py
index 8351b59e4..4bfd7e0dd 100644
--- a/apps/api/src/services/agent99_telegram_lifecycle.py
+++ b/apps/api/src/services/agent99_telegram_lifecycle.py
@@ -25,9 +25,62 @@ _SEVERITY_LABELS = {
"info": "資訊 / INFO",
}
+_STAGE_STATUS_LABELS = {
+ "pending": "待回報 / PENDING",
+ "running": "執行中 / RUNNING",
+ "passed": "完成 / PASSED",
+ "failed": "失敗 / FAILED",
+ "not_applicable": "不適用 / N/A",
+}
+
+_STAGE_STATUS_COMPACT = {
+ "pending": "PENDING",
+ "running": "RUNNING",
+ "passed": "PASSED",
+ "failed": "FAILED",
+ "not_applicable": "N/A",
+}
+
+_LIFECYCLE_STAGES = (
+ ("Received", "received_status"),
+ ("Check", "check_status"),
+ ("Apply", "apply_status"),
+ ("Verifier", "verifier_status"),
+ ("Closure", "closure_status"),
+)
+
_DURABLE_RECONCILE_DELAYS_SECONDS = (0.25, 0.5, 1.0)
+def _escape_bounded(value: object, limit: int) -> str:
+ """Escape without cutting an HTML entity or overflowing Telegram."""
+
+ 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 "")
+
+
+def _stage_status(
+ payload: Agent99TelegramLifecycleRequest,
+ field_name: str,
+) -> str:
+ """Missing evidence stays visibly pending; it is never inferred green."""
+
+ return str(getattr(payload, field_name) or "pending")
+
+
+def _pending_value(value: object | None) -> object:
+ return value if value not in (None, "") else "未提供 / PENDING"
+
+
def _bounded_reconcile_receipt(
payload: Agent99TelegramLifecycleRequest,
*,
@@ -49,28 +102,57 @@ def _bounded_reconcile_receipt(
def _format_lifecycle_card(payload: Agent99TelegramLifecycleRequest) -> str:
- def esc(value: object) -> str:
- return html.escape(str(value or ""))
+ stage_lines = [
+ (
+ f"├ {stage_name}:"
+ f"{_STAGE_STATUS_LABELS[_stage_status(payload, field_name)]}"
+ )
+ for stage_name, field_name in _LIFECYCLE_STAGES
+ ]
return "\n".join(
[
- f"Agent99 事件|{esc(payload.title)}",
+ f"Agent99 事件|{_escape_bounded(payload.title, 180)}",
(
- f"狀態:{esc(_LIFECYCLE_LABELS[payload.lifecycle])}"
- f" 等級:{esc(_SEVERITY_LABELS[payload.severity])}"
+ f"狀態:{_LIFECYCLE_LABELS[payload.lifecycle]}"
+ f" 等級:{_SEVERITY_LABELS[payload.severity]}"
),
"────────────────────",
- f"資產 {esc(payload.target)}",
- f"影響 {esc(payload.impact)}",
- f"AI 處置 {esc(payload.action)}",
- f"驗證 {esc(payload.verification)}",
+ f"資產 {_escape_bounded(payload.target, 180)}",
+ f"影響 {_escape_bounded(payload.impact, 300)}",
+ f"AI 處置 {_escape_bounded(payload.action, 360)}",
+ f"驗證 {_escape_bounded(payload.verification, 300)}",
+ "────────────────────",
+ "AI Agent 執行鏈",
+ *stage_lines,
+ (
+ "執行器:"
+ f"{_escape_bounded(_pending_value(payload.executor_name), 140)} "
+ "Verifier:"
+ f"{_escape_bounded(_pending_value(payload.verifier_name), 140)}"
+ ),
+ "同一執行識別",
+ (
+ "Run:"
+ f"{_escape_bounded(_pending_value(payload.run_id), 150)}"
+ ),
+ (
+ "Trace:"
+ f"{_escape_bounded(_pending_value(payload.trace_id), 150)}"
+ ),
+ (
+ "Work Item:"
+ f"{_escape_bounded(_pending_value(payload.work_item_id), 150)}"
+ ),
"────────────────────",
(
- f"事件:{esc(payload.incident_id)} "
- f"發生:{payload.occurrences} 次 耗時:{esc(payload.duration_text)}"
+ "事件:"
+ f"{_escape_bounded(payload.incident_id, 160)} "
+ f"發生:{payload.occurrences} 次 "
+ f"耗時:{_escape_bounded(payload.duration_text, 80)}"
),
- f"下一次更新:{esc(payload.next_update)}",
- f"時間:{esc(payload.occurred_at.isoformat())}",
+ f"下一次更新:{_escape_bounded(payload.next_update, 300)}",
+ f"時間:{_escape_bounded(payload.occurred_at.isoformat(), 64)}",
]
)[:4096]
@@ -78,32 +160,44 @@ def _format_lifecycle_card(payload: Agent99TelegramLifecycleRequest) -> str:
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 "")
+ stage_summary = "|".join(
+ (
+ f"{stage_name}:"
+ f"{_STAGE_STATUS_COMPACT[_stage_status(payload, field_name)]}"
+ )
+ for stage_name, field_name in _LIFECYCLE_STAGES
+ )
return "\n".join(
[
(
- f"{esc(_LIFECYCLE_LABELS[payload.lifecycle], 48)}|"
- f"{esc(_SEVERITY_LABELS[payload.severity], 32)}"
+ f"{_escape_bounded(_LIFECYCLE_LABELS[payload.lifecycle], 48)}|"
+ f"{_escape_bounded(_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"{_escape_bounded(payload.title, 90)}",
+ f"資產:{_escape_bounded(payload.target, 64)}",
+ f"鏈路:{stage_summary}",
+ f"Agent99:{_escape_bounded(payload.action, 115)}",
+ f"驗證:{_escape_bounded(payload.verification, 90)}",
(
- f"事件 {esc(payload.incident_id, 180)}|"
- f"發生 {payload.occurrences} 次|{esc(payload.duration_text, 60)}"
+ "執行器:"
+ f"{_escape_bounded(_pending_value(payload.executor_name), 52)}|"
+ "Verifier:"
+ f"{_escape_bounded(_pending_value(payload.verifier_name), 52)}"
+ ),
+ (
+ "ID|Run:"
+ f"{_escape_bounded(_pending_value(payload.run_id), 48)}|"
+ "Trace:"
+ f"{_escape_bounded(_pending_value(payload.trace_id), 48)}|"
+ "WI:"
+ f"{_escape_bounded(_pending_value(payload.work_item_id), 48)}"
+ ),
+ (
+ "事件 "
+ f"{_escape_bounded(payload.incident_id, 80)}|"
+ f"發生 {payload.occurrences} 次|"
+ f"{_escape_bounded(payload.duration_text, 40)}"
),
]
)
diff --git a/apps/api/src/services/security_interceptor.py b/apps/api/src/services/security_interceptor.py
index 65280a149..f128df079 100644
--- a/apps/api/src/services/security_interceptor.py
+++ b/apps/api/src/services/security_interceptor.py
@@ -554,9 +554,13 @@ class TelegramSecurityInterceptor:
# 2026-04-01 Claude Code (ADR-050): 支援 read-only info actions (2-part format)
# 2026-04-20 P0.1 ogt + Claude Opus 4.7: drift_view_page 納入 INFO_ACTIONS
# payload 格式: drift_view_page:{report_id}_{page}(底線分隔,不跟冒號衝突)
- INFO_ACTIONS = {"detail", "reanalyze", "history", "drift_view_page"}
+ from src.services.telegram_button_registry import (
+ registered_info_callback_actions,
+ )
+
+ info_actions = registered_info_callback_actions()
parts = callback_data.split(":")
- if len(parts) == 2 and parts[0] in INFO_ACTIONS:
+ if len(parts) == 2 and parts[0] in info_actions:
return {
"action": parts[0],
"incident_id": parts[1],
diff --git a/apps/api/src/services/telegram_button_registry.py b/apps/api/src/services/telegram_button_registry.py
new file mode 100644
index 000000000..7b2171f28
--- /dev/null
+++ b/apps/api/src/services/telegram_button_registry.py
@@ -0,0 +1,222 @@
+"""Machine-readable Telegram inline-button contracts.
+
+Only callback actions that resolve to one explicit handler contract may be
+rendered. Transport readiness is evaluated separately by TelegramGateway;
+this registry prevents an enabled transport from reviving legacy ghost
+buttons.
+"""
+
+from __future__ import annotations
+
+import re
+from dataclasses import asdict, dataclass
+
+
+@dataclass(frozen=True)
+class TelegramButtonActionContract:
+ action: str
+ callback_format: str
+ handler: str
+ risk: str
+ executor: str
+ verifier: str
+ state: str = "active"
+
+
+_STATIC_ACTIONS: dict[str, TelegramButtonActionContract] = {
+ "detail": TelegramButtonActionContract(
+ "detail", "info", "TelegramGateway._send_incident_detail", "low",
+ "telegram_info_query", "telegram_callback_reply_receipt",
+ ),
+ "history": TelegramButtonActionContract(
+ "history", "info", "TelegramGateway._send_incident_history", "low",
+ "telegram_info_query", "telegram_callback_reply_receipt",
+ ),
+ "reanalyze": TelegramButtonActionContract(
+ "reanalyze", "info", "TelegramGateway._send_reanalyze_result", "low",
+ "telegram_reanalysis_query", "telegram_callback_reply_receipt",
+ ),
+ "drift_view_page": TelegramButtonActionContract(
+ "drift_view_page", "info", "TelegramGateway._send_drift_diff_detail", "low",
+ "telegram_info_query", "telegram_callback_reply_receipt",
+ ),
+ "approve": TelegramButtonActionContract(
+ "approve", "nonce", "TelegramGateway.handle_callback", "high",
+ "approval_control_plane", "approval_and_execution_receipt",
+ ),
+ "reject": TelegramButtonActionContract(
+ "reject", "nonce", "TelegramGateway.handle_callback", "medium",
+ "approval_control_plane", "approval_state_receipt",
+ ),
+ "silence": TelegramButtonActionContract(
+ "silence", "nonce", "TelegramGateway._handle_silence", "medium",
+ "telegram_silence_store", "telegram_callback_reply_receipt",
+ ),
+ "snooze": TelegramButtonActionContract(
+ "snooze", "nonce", "TelegramGateway._handle_snooze", "low",
+ "telegram_snooze_store", "telegram_callback_reply_receipt",
+ ),
+ "tune": TelegramButtonActionContract(
+ "tune", "nonce", "TelegramGateway._handle_auto_tuning", "low",
+ "shadow_candidate_recorder", "candidate_record_receipt",
+ state="shadow_candidate_only",
+ ),
+ "log_manual_fix": TelegramButtonActionContract(
+ "log_manual_fix", "nonce", "TelegramGateway.handle_callback:log_manual_fix", "low",
+ "incident_timeline", "timeline_write_receipt",
+ ),
+ "drift_view": TelegramButtonActionContract(
+ "drift_view", "nonce", "TelegramGateway._handle_drift_action", "low",
+ "drift_readback", "telegram_callback_reply_receipt",
+ ),
+ "drift_adopt": TelegramButtonActionContract(
+ "drift_adopt", "nonce", "TelegramGateway._handle_drift_action", "high",
+ "drift_control_plane", "drift_post_verifier",
+ ),
+ "drift_revert": TelegramButtonActionContract(
+ "drift_revert", "nonce", "TelegramGateway._handle_drift_action", "high",
+ "drift_control_plane", "drift_post_verifier",
+ ),
+ "la": TelegramButtonActionContract(
+ "la", "llm_short_id", "TelegramGateway._handle_llm_action_callback", "dynamic",
+ "callback_dispatcher", "callback_dispatch_receipt",
+ ),
+}
+
+_AI_ADVISORY_ACTIONS = {
+ "ai_advisory_handled",
+ "ai_advisory_snooze",
+ "ai_advisory_view",
+ "ai_advisory_produce_cmd",
+}
+_LLM_SHORT_ID_RE = re.compile(r"^[0-9a-f]{16}$")
+
+
+def _callback_action(callback_data: str) -> str:
+ return str(callback_data or "").split(":", 1)[0].strip()
+
+
+def _registry_action_contract(action: str) -> TelegramButtonActionContract | None:
+ contract = _STATIC_ACTIONS.get(action)
+ if contract is not None:
+ return contract
+ if action in _AI_ADVISORY_ACTIONS:
+ return TelegramButtonActionContract(
+ action,
+ "advisory",
+ "TelegramGateway._handle_ai_advisory_action",
+ "low",
+ "ai_advisory_control_plane",
+ "telegram_callback_reply_receipt",
+ )
+
+ # The YAML registry is the existing source of truth for category actions.
+ # Import lazily so registry validation never creates a module cycle.
+ from src.services.callback_dispatcher import get_action_spec
+
+ spec = get_action_spec(action)
+ if spec is None:
+ return None
+ return TelegramButtonActionContract(
+ action=spec.name,
+ callback_format=spec.callback_format,
+ handler="callback_dispatcher.dispatch_action",
+ risk=spec.risk,
+ executor=f"mcp:{spec.mcp_provider}/{spec.mcp_tool}",
+ verifier="callback_dispatch_receipt",
+ )
+
+
+def resolve_callback_action_contract(
+ callback_data: str,
+) -> TelegramButtonActionContract | None:
+ """Resolve and validate a callback payload without persisting its nonce."""
+ raw = str(callback_data or "").strip()
+ if not raw or len(raw.encode("utf-8")) > 64:
+ return None
+ parts = raw.split(":")
+ action = _callback_action(raw)
+ contract = _registry_action_contract(action)
+ if contract is None:
+ return None
+
+ if contract.callback_format == "info":
+ return contract if len(parts) == 2 and bool(parts[1]) else None
+ if contract.callback_format == "nonce":
+ return contract if len(parts) in {4, 5} and all(parts[1:4]) else None
+ if contract.callback_format == "llm_short_id":
+ return (
+ contract
+ if len(parts) == 2 and _LLM_SHORT_ID_RE.fullmatch(parts[1])
+ else None
+ )
+ if contract.callback_format == "advisory":
+ return contract if len(parts) == 3 and all(parts[1:]) else None
+ return None
+
+
+def callback_action_is_registered(callback_data: str) -> bool:
+ return resolve_callback_action_contract(callback_data) is not None
+
+
+def registered_info_callback_actions() -> frozenset[str]:
+ """Return every two-part callback action accepted by the dispatcher."""
+ from src.services.callback_dispatcher import load_action_registry
+
+ names = {
+ action
+ for action, contract in _STATIC_ACTIONS.items()
+ if contract.callback_format == "info"
+ }
+ names.update(
+ spec.name
+ for spec in load_action_registry().values()
+ if spec.callback_format == "info"
+ )
+ return frozenset(names)
+
+
+def telegram_button_registry_snapshot() -> dict[str, object]:
+ """Return a redaction-safe registry readback for health and tests."""
+ from src.services.callback_dispatcher import load_action_registry
+
+ contracts = list(_STATIC_ACTIONS.values())
+ contracts.extend(
+ TelegramButtonActionContract(
+ action=action,
+ callback_format="advisory",
+ handler="TelegramGateway._handle_ai_advisory_action",
+ risk="low",
+ executor="ai_advisory_control_plane",
+ verifier="telegram_callback_reply_receipt",
+ )
+ for action in sorted(_AI_ADVISORY_ACTIONS)
+ )
+ contracts.extend(
+ TelegramButtonActionContract(
+ action=spec.name,
+ callback_format=spec.callback_format,
+ handler="callback_dispatcher.dispatch_action",
+ risk=spec.risk,
+ executor=f"mcp:{spec.mcp_provider}/{spec.mcp_tool}",
+ verifier="callback_dispatch_receipt",
+ )
+ for spec in load_action_registry().values()
+ if spec.name not in _STATIC_ACTIONS
+ )
+ rows = sorted((asdict(item) for item in contracts), key=lambda item: item["action"])
+ return {
+ "schema_version": "telegram_button_action_registry_v1",
+ "registered_action_count": len(rows),
+ "unknown_action_policy": "do_not_render",
+ "actions": rows,
+ }
+
+
+__all__ = [
+ "TelegramButtonActionContract",
+ "callback_action_is_registered",
+ "registered_info_callback_actions",
+ "resolve_callback_action_contract",
+ "telegram_button_registry_snapshot",
+]
diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py
index 7b4191aa5..faad17160 100644
--- a/apps/api/src/services/telegram_gateway.py
+++ b/apps/api/src/services/telegram_gateway.py
@@ -32,6 +32,7 @@ import secrets
from collections.abc import Mapping
from dataclasses import dataclass, field
from datetime import UTC, datetime
+from urllib.parse import urlsplit
from uuid import NAMESPACE_URL, UUID, uuid5
import httpx
@@ -75,6 +76,7 @@ from src.services.security_interceptor import (
UserNotWhitelistedError,
get_security_interceptor,
)
+from src.services.telegram_button_registry import callback_action_is_registered
# =============================================================================
# Snooze/Silence Redis Keys (2026-03-27 P1 優化)
@@ -133,6 +135,7 @@ POLLING_LEADER_KEY = "telegram:polling:leader"
POLLING_LEADER_TTL = 45 # seconds - Pod 宕掉後 45s 自動轉移
POLLING_LEADER_RENEW = 20 # seconds - 每 20s 續約
POLLING_LEADER_WATCH = 30 # seconds - 非 Leader Pod 每 30s 嘗試接管
+CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS = 24 * 60 * 60
logger = structlog.get_logger(__name__)
_TELEGRAM_BOT_URL_RE = re.compile(r"(api\.telegram\.org/bot)[^/\s]+")
@@ -1024,6 +1027,65 @@ def normalize_telegram_send_message_payload(method: str, payload: dict) -> dict:
return normalized_payload
+def _verified_telegram_button_url(value: object) -> bool:
+ """Accept only credential-free HTTPS links to the canonical AwoooP UI."""
+ raw = str(value or "").strip()
+ if not raw:
+ return False
+ parsed = urlsplit(raw)
+ canonical = urlsplit(AWOOOP_WEB_BASE_URL)
+ return bool(
+ parsed.scheme == "https"
+ and parsed.hostname
+ and canonical.hostname
+ and parsed.hostname == canonical.hostname
+ and parsed.port == canonical.port
+ and parsed.username is None
+ and parsed.password is None
+ )
+
+
+def filter_telegram_reply_markup(
+ reply_markup: object,
+ *,
+ interactive_buttons_ready: bool,
+) -> dict | None:
+ """Fail closed when callback ingress or an action contract is missing.
+
+ URL buttons remain available because they do not depend on Telegram
+ callback transport. Unknown callbacks are removed even when the transport
+ is healthy, preventing legacy builders from creating ghost controls.
+ """
+ if not isinstance(reply_markup, dict):
+ return None
+ inline_keyboard = reply_markup.get("inline_keyboard")
+ if not isinstance(inline_keyboard, list):
+ return dict(reply_markup)
+
+ safe_rows: list[list[dict]] = []
+ for row in inline_keyboard:
+ if not isinstance(row, list):
+ continue
+ safe_row: list[dict] = []
+ for button in row:
+ if not isinstance(button, dict):
+ continue
+ text = str(button.get("text") or "").strip()
+ callback_data = str(button.get("callback_data") or "").strip()
+ url = str(button.get("url") or "").strip()
+ if (
+ interactive_buttons_ready
+ and callback_data
+ and callback_action_is_registered(callback_data)
+ ):
+ safe_row.append({"text": text, "callback_data": callback_data})
+ elif not callback_data and _verified_telegram_button_url(url):
+ safe_row.append({"text": text, "url": url})
+ if safe_row:
+ safe_rows.append(safe_row)
+ return {"inline_keyboard": safe_rows} if safe_rows else None
+
+
def _telegram_send_delivery_succeeded(result: object) -> bool:
"""Require affirmative provider delivery evidence from ``_send_request``.
@@ -4604,7 +4666,7 @@ class SentryErrorMessage:
Sentry 錯誤訊息 (SENTRY_ERROR)
2026-03-29 ogt: 新增,用於 Sentry 錯誤通知
- 按鈕: [🔍 查看詳情] [🔕 靜默 1h]
+ 按鈕: [🔕 靜默 1h];Sentry URL 由訊息中的 HTTPS link 提供
"""
error_id: str # Sentry Issue ID
error_type: str # TypeError, ValueError, etc.
@@ -4664,7 +4726,7 @@ class ResourceWarnMessage:
資源告警訊息 (RESOURCE_WARN)
2026-03-29 ogt: 新增,用於資源耗盡警告
- 按鈕: [⚡ 自動擴展] [🔕 靜默 1h]
+ 按鈕: [🔕 靜默 1h];未綁 executor/verifier 前不渲染擴展操作
"""
resource_id: str # RES-YYYYMMDD-XXXX
pod_name: str # Pod 名稱
@@ -5493,6 +5555,8 @@ class TelegramGateway:
self._polling_active = False
self._polling_task: asyncio.Task | None = None
self._last_update_id = 0
+ self._last_callback_ingress_receipt_at: datetime | None = None
+ self._last_callback_ingress_source = ""
# 2026-04-01 Claude Code: 分散式 Leader Election (防 2-Pod 409 互搶)
self._pod_id = os.environ.get("POD_NAME", os.urandom(8).hex())
self._leader_task: asyncio.Task | None = None
@@ -5545,6 +5609,116 @@ class TelegramGateway:
"""Runtime binding; new monitoring sends are still policy-gated."""
return settings.SRE_GROUP_CHAT_ID
+ def record_callback_ingress_receipt(
+ self,
+ *,
+ source: str,
+ update_id: int | None,
+ ) -> None:
+ """Record one process-local callback receipt without callback payload."""
+ self._last_callback_ingress_receipt_at = datetime.now(UTC)
+ self._last_callback_ingress_source = str(source or "unknown")[:80]
+ if isinstance(update_id, int):
+ self._last_update_id = max(int(self._last_update_id or 0), update_id)
+
+ def callback_ingress_status(self) -> dict[str, object]:
+ """Return callback transport truth used by health and render gates."""
+ now = datetime.now(UTC)
+ last_receipt = getattr(self, "_last_callback_ingress_receipt_at", None)
+ source = str(getattr(self, "_last_callback_ingress_source", "") or "")
+ age_seconds: int | None = None
+ if isinstance(last_receipt, datetime):
+ normalized = (
+ last_receipt.replace(tzinfo=UTC)
+ if last_receipt.tzinfo is None
+ else last_receipt.astimezone(UTC)
+ )
+ age_seconds = max(0, int((now - normalized).total_seconds()))
+ receipt_fresh = bool(
+ age_seconds is not None
+ and age_seconds <= CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS
+ )
+ polling_declared_active = bool(getattr(self, "_polling_active", False))
+ polling_task = getattr(self, "_polling_task", None)
+ polling_task_active = bool(
+ polling_task is not None
+ and hasattr(polling_task, "done")
+ and not polling_task.done()
+ )
+ polling_active = bool(polling_declared_active and polling_task_active)
+ if polling_active:
+ mode = "api_long_polling"
+ owner = "awoooi_api"
+ elif receipt_fresh:
+ mode = "external_callback_forward"
+ owner = source or "external_forwarder"
+ elif polling_declared_active:
+ mode = "api_long_polling_stale"
+ owner = "awoooi_api"
+ elif bool(settings.TELEGRAM_ENABLE_POLLING):
+ mode = "api_long_polling_inactive"
+ owner = "awoooi_api"
+ else:
+ mode = "external_callback_forward_unverified"
+ owner = "external_forwarder_unverified"
+ configured = bool(self.bot_token and self.chat_id)
+ interactive_ready = bool(configured and (polling_active or receipt_fresh))
+ return {
+ "schema_version": "telegram_callback_ingress_status_v1",
+ "mode": mode,
+ "owner": owner,
+ "configured": configured,
+ "polling_active": polling_active,
+ "polling_declared_active": polling_declared_active,
+ "polling_task_active": polling_task_active,
+ "receipt_available": isinstance(last_receipt, datetime),
+ "receipt_fresh": receipt_fresh,
+ "receipt_source": source or None,
+ "last_receipt_at": (
+ last_receipt.astimezone(UTC).isoformat()
+ if isinstance(last_receipt, datetime) and last_receipt.tzinfo
+ else last_receipt.replace(tzinfo=UTC).isoformat()
+ if isinstance(last_receipt, datetime)
+ else None
+ ),
+ "receipt_age_seconds": age_seconds,
+ "receipt_max_age_seconds": CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS,
+ "stale": bool(isinstance(last_receipt, datetime) and not receipt_fresh),
+ "interactive_buttons_ready": interactive_ready,
+ "fail_closed_reason": (
+ None if interactive_ready else "callback_ingress_not_verified"
+ ),
+ }
+
+ def _apply_callback_ingress_render_gate(self, payload: dict) -> dict:
+ reply_markup = payload.get("reply_markup")
+ if not isinstance(reply_markup, dict):
+ return payload
+ ingress = self.callback_ingress_status()
+ filtered = filter_telegram_reply_markup(
+ reply_markup,
+ interactive_buttons_ready=bool(
+ ingress.get("interactive_buttons_ready") is True
+ ),
+ )
+ if filtered == reply_markup:
+ return payload
+ guarded = dict(payload)
+ if filtered is None:
+ guarded.pop("reply_markup", None)
+ else:
+ guarded["reply_markup"] = filtered
+ logger.warning(
+ "telegram_callback_button_render_fail_closed",
+ callback_ingress_mode=ingress.get("mode"),
+ interactive_buttons_ready=ingress.get("interactive_buttons_ready"),
+ original_button_count=_reply_markup_summary(
+ {"reply_markup": reply_markup}
+ ).get("button_count"),
+ rendered_button_count=_reply_markup_summary(guarded).get("button_count"),
+ )
+ return guarded
+
@staticmethod
def _blocked_route_receipt(
classifier: str,
@@ -6605,6 +6779,7 @@ class TelegramGateway:
raise TelegramGatewayError("HTTP client not initialized")
payload = normalize_telegram_send_message_payload(method, payload)
+ payload = self._apply_callback_ingress_render_gate(payload)
source_envelope_extra = payload.pop(_AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY, None)
if canonical_route_receipt is not None:
source_envelope_extra = dict(source_envelope_extra or {})
@@ -7233,7 +7408,7 @@ class TelegramGateway:
# 自動調優按鈕 (v7.0)
if include_auto_tuning and auto_tuning_command:
tuning_nonce = self._security.generate_callback_nonce(approval_id, "tune")
- buttons.append([{"text": "⚡ 執行自動調優", "callback_data": tuning_nonce}])
+ buttons.append([{"text": "📝 記錄調優候選", "callback_data": tuning_nonce}])
awooop_row = _awooop_truth_chain_button_row(incident_id)
if awooop_row:
@@ -7318,7 +7493,7 @@ class TelegramGateway:
if include_auto_tuning and auto_tuning_command:
tuning_nonce = self._security.generate_callback_nonce(approval_id, "tune")
buttons.append([
- {"text": "⚡ 執行自動調優", "callback_data": tuning_nonce}
+ {"text": "📝 記錄調優候選", "callback_data": tuning_nonce}
])
return {"inline_keyboard": buttons}
@@ -8741,13 +8916,11 @@ class TelegramGateway:
def _build_sentry_keyboard(self, error_id: str) -> dict:
"""建立 Sentry 錯誤訊息按鈕"""
- view_nonce = self._security.generate_callback_nonce(error_id, "view")
silence_nonce = self._security.generate_callback_nonce(error_id, "silence")
return {
"inline_keyboard": [
[
- {"text": "🔍 查看詳情", "callback_data": view_nonce},
{"text": "🔕 靜默 1h", "callback_data": silence_nonce},
]
]
@@ -8755,13 +8928,11 @@ class TelegramGateway:
def _build_resource_keyboard(self, resource_id: str) -> dict:
"""建立資源告警按鈕"""
- scale_nonce = self._security.generate_callback_nonce(resource_id, "scale")
silence_nonce = self._security.generate_callback_nonce(resource_id, "silence")
return {
"inline_keyboard": [
[
- {"text": "⚡ 自動擴展", "callback_data": scale_nonce},
{"text": "🔕 靜默 1h", "callback_data": silence_nonce},
]
]
@@ -9593,7 +9764,7 @@ class TelegramGateway:
await self._answer_callback(
callback_query_id,
"tune",
- text="⚡ 調優指令已記錄 (Shadow Mode)",
+ text="📝 調優候選已記錄 (Shadow Mode)",
)
# 更新訊息
await self._update_message_after_action(
@@ -10448,7 +10619,7 @@ class TelegramGateway:
elif action == "reject":
stamp = f"❌ 已由 @{username} 拒絕執行"
elif action == "tune":
- stamp = f"⚡ 已由 @{username} 觸發自動調優 (Shadow Mode)"
+ stamp = f"📝 已由 @{username} 記錄調優候選 (Shadow Mode)"
if extra_info:
stamp += "\n📝 指令已記錄"
elif action == "snooze":
@@ -12902,7 +13073,13 @@ class TelegramGateway:
elif message:
await self._handle_chat_message(update_id, message)
- async def _handle_callback_query(self, update_id: int, callback_query: dict) -> None:
+ async def _handle_callback_query(
+ self,
+ update_id: int,
+ callback_query: dict,
+ *,
+ ingress_source: str = "api_long_polling",
+ ) -> None:
"""處理按鈕點擊更新"""
callback_query_id = callback_query.get("id")
callback_data = callback_query.get("data")
@@ -12913,6 +13090,11 @@ class TelegramGateway:
logger.warning("telegram_callback_invalid", update_id=update_id)
return
+ self.record_callback_ingress_receipt(
+ source=ingress_source,
+ update_id=update_id,
+ )
+
username = user.get("username") or user.get("first_name") or str(user_id)
original_text = callback_query.get("message", {}).get("text", "")
message = callback_query.get("message", {})
@@ -12947,7 +13129,7 @@ class TelegramGateway:
chat_id=chat_id,
)
- if result.get("success"):
+ if result.get("success") and not result.get("info_action"):
# 執行資料庫更新 (簽核/拒絕)
await self._execute_approval_action(
action=result["action"],
diff --git a/apps/api/tests/test_agent99_telegram_lifecycle_api.py b/apps/api/tests/test_agent99_telegram_lifecycle_api.py
index dfdbbd5c4..461530ca4 100644
--- a/apps/api/tests/test_agent99_telegram_lifecycle_api.py
+++ b/apps/api/tests/test_agent99_telegram_lifecycle_api.py
@@ -34,6 +34,15 @@ def payload() -> dict:
"lifecycle": "investigating",
"state_key": "investigating|warning",
"run_id": "run-agent99-20260715-01",
+ "trace_id": "trace-agent99-20260715-01",
+ "work_item_id": "work-item-agent99-20260715-01",
+ "executor_name": "Agent99:Recover",
+ "verifier_name": "agent99-recover-post-condition-v1",
+ "received_status": "passed",
+ "check_status": "passed",
+ "apply_status": "running",
+ "verifier_status": "pending",
+ "closure_status": "pending",
"title": "Host 110 主機效能異常",
"target": "192.168.0.110",
"impact": "資源壓力可能降低服務回應速度。",
@@ -90,6 +99,28 @@ def test_agent99_client_retries_one_202_with_exact_db_only_receipt() -> None:
assert "reconcileProviderSendPerformed" in send_section
assert "provider_message_id" in send_section
assert "destination_binding" in send_section
+ for lifecycle_field in (
+ "trace_id",
+ "work_item_id",
+ "executor_name",
+ "verifier_name",
+ "received_status",
+ "check_status",
+ "apply_status",
+ "verifier_status",
+ "closure_status",
+ ):
+ assert f"{lifecycle_field} =" in send_section
+ for card_field in (
+ "receivedStatus",
+ "checkStatus",
+ "applyStatus",
+ "verifierStatus",
+ "closureStatus",
+ "traceKey",
+ "workItemKey",
+ ):
+ assert f"$card.{card_field}" in send_section
assert "X-Agent99-Lifecycle-Token" not in send_section
selftest_section = source.split(
"function Invoke-AgentTelegramDeliverySelfTest {",
@@ -137,6 +168,72 @@ def test_lifecycle_payload_rejects_unbounded_content() -> None:
},
}
)
+ with pytest.raises(ValidationError):
+ Agent99TelegramLifecycleRequest.model_validate(
+ {**payload(), "closure_status": "resolved_without_receipt"}
+ )
+
+
+def test_lifecycle_card_exposes_agent99_stage_and_identity_receipts() -> None:
+ request = Agent99TelegramLifecycleRequest.model_validate(
+ {
+ **payload(),
+ "check_status": "failed",
+ "apply_status": "passed",
+ "verifier_status": "failed",
+ "closure_status": "failed",
+ }
+ )
+
+ card = lifecycle_service._format_lifecycle_card(request)
+ caption = lifecycle_service._format_lifecycle_caption(request)
+
+ assert "AI Agent 執行鏈" in card
+ assert "Received:完成 / PASSED" in card
+ assert "Check:失敗 / FAILED" in card
+ assert "Apply:完成 / PASSED" in card
+ assert "Verifier:失敗 / FAILED" in card
+ assert "Closure:失敗 / FAILED" in card
+ assert "run-agent99-20260715-01" in card
+ assert "trace-agent99-20260715-01" in card
+ assert "work-item-agent99-20260715-01" in card
+ assert "Agent99:Recover" in card
+ assert "agent99-recover-post-condition-v1" in card
+ assert "Received:PASSED" in caption
+ assert "Check:FAILED" in caption
+ assert "Closure:FAILED" in caption
+ assert "Trace:trace-agent99-20260715-01" in caption
+
+
+def test_lifecycle_card_missing_stage_evidence_stays_visibly_pending() -> None:
+ optional_receipt_fields = {
+ "run_id",
+ "trace_id",
+ "work_item_id",
+ "executor_name",
+ "verifier_name",
+ "received_status",
+ "check_status",
+ "apply_status",
+ "verifier_status",
+ "closure_status",
+ }
+ request = Agent99TelegramLifecycleRequest.model_validate(
+ {
+ key: value
+ for key, value in payload().items()
+ if key not in optional_receipt_fields
+ }
+ )
+
+ card = lifecycle_service._format_lifecycle_card(request)
+ caption = lifecycle_service._format_lifecycle_caption(request)
+
+ assert card.count("待回報 / PENDING") >= 5
+ assert card.count("未提供 / PENDING") >= 5
+ assert "Received:PENDING" in caption
+ assert "Closure:PENDING" in caption
+ assert "ID|Run:未提供 / PENDING" in caption
def test_visual_caption_stays_within_telegram_limit_without_broken_html() -> None:
diff --git a/apps/api/tests/test_telegram_button_consistency.py b/apps/api/tests/test_telegram_button_consistency.py
index 6e0931b00..12d4e12b0 100644
--- a/apps/api/tests/test_telegram_button_consistency.py
+++ b/apps/api/tests/test_telegram_button_consistency.py
@@ -14,6 +14,8 @@ Telegram 按鈕一致性測試
import re
+from src.services.telegram_button_registry import registered_info_callback_actions
+
def _read_gateway() -> str:
with open("src/services/telegram_gateway.py", encoding="utf-8") as f:
@@ -229,16 +231,14 @@ class TestCallbackHandlerCompleteness:
"""驗證 detail/history 按鈕有對應的 handler(鬼魂按鈕鐵律三條件之二)"""
def test_detail_action_in_info_actions_whitelist(self):
- """detail action 在 INFO_ACTIONS 白名單中"""
+ """detail action 在 machine-readable info registry 中"""
source = _read_security_interceptor()
- assert '"detail"' in source, "detail 必須在 INFO_ACTIONS 白名單"
- # 確認是在 INFO_ACTIONS 集合定義中
- assert "INFO_ACTIONS" in source
+ assert "registered_info_callback_actions" in source
+ assert "detail" in registered_info_callback_actions()
def test_history_action_in_info_actions_whitelist(self):
- """history action 在 INFO_ACTIONS 白名單中"""
- source = _read_security_interceptor()
- assert '"history"' in source, "history 必須在 INFO_ACTIONS 白名單"
+ """history action 在 machine-readable info registry 中"""
+ assert "history" in registered_info_callback_actions()
def test_detail_handler_exists_in_handle_callback(self):
"""handle_callback 有 detail handler 分支"""
diff --git a/apps/api/tests/test_telegram_callback_ingress_contract.py b/apps/api/tests/test_telegram_callback_ingress_contract.py
new file mode 100644
index 000000000..fc4b7ece3
--- /dev/null
+++ b/apps/api/tests/test_telegram_callback_ingress_contract.py
@@ -0,0 +1,366 @@
+from __future__ import annotations
+
+from datetime import UTC, datetime, timedelta
+from types import SimpleNamespace
+
+import pytest
+from fastapi import HTTPException
+
+from src.api.v1 import telegram as telegram_api
+from src.api.v1 import telegram_webhook as telegram_webhook_api
+from src.services import telegram_gateway as telegram_gateway_module
+from src.services.callback_dispatcher import load_action_registry
+from src.services.telegram_button_registry import (
+ callback_action_is_registered,
+ registered_info_callback_actions,
+ resolve_callback_action_contract,
+ telegram_button_registry_snapshot,
+)
+from src.services.telegram_gateway import (
+ CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS,
+ TelegramGateway,
+ filter_telegram_reply_markup,
+)
+
+
+class _ConfiguredGateway(TelegramGateway):
+ @property
+ def bot_token(self) -> str:
+ return "test-configured"
+
+ @property
+ def chat_id(self) -> str:
+ return "test-configured"
+
+
+class _Security:
+ @staticmethod
+ def generate_callback_nonce(approval_id: str, action: str) -> str:
+ return f"{action}:{approval_id}:1:nonce"
+
+
+def _callback_rows(markup: dict | None) -> list[dict]:
+ return [
+ button
+ for row in (markup or {}).get("inline_keyboard", [])
+ for button in row
+ ]
+
+
+def test_callback_ingress_defaults_to_unverified_fail_closed() -> None:
+ gateway = _ConfiguredGateway()
+
+ status = gateway.callback_ingress_status()
+
+ assert status["mode"] == "external_callback_forward_unverified"
+ assert status["polling_active"] is False
+ assert status["receipt_available"] is False
+ assert status["interactive_buttons_ready"] is False
+ assert status["fail_closed_reason"] == "callback_ingress_not_verified"
+
+
+def test_callback_ingress_receipt_has_24_hour_freshness_contract() -> None:
+ gateway = _ConfiguredGateway()
+ assert CALLBACK_INGRESS_RECEIPT_MAX_AGE_SECONDS == 24 * 60 * 60
+
+ gateway._last_callback_ingress_source = "canonical_webhook"
+ gateway._last_callback_ingress_receipt_at = datetime.now(UTC) - timedelta(hours=23)
+ fresh = gateway.callback_ingress_status()
+ assert fresh["receipt_fresh"] is True
+ assert fresh["interactive_buttons_ready"] is True
+
+ gateway._last_callback_ingress_receipt_at = datetime.now(UTC) - timedelta(hours=25)
+ stale = gateway.callback_ingress_status()
+ assert stale["stale"] is True
+ assert stale["interactive_buttons_ready"] is False
+
+
+def test_polling_flag_without_live_task_is_stale_not_ready() -> None:
+ gateway = _ConfiguredGateway()
+ gateway._polling_active = True
+ gateway._polling_task = None
+
+ status = gateway.callback_ingress_status()
+
+ assert status["mode"] == "api_long_polling_stale"
+ assert status["polling_declared_active"] is True
+ assert status["polling_task_active"] is False
+ assert status["polling_active"] is False
+ assert status["interactive_buttons_ready"] is False
+
+
+def test_fail_closed_markup_keeps_only_verified_https_urls() -> None:
+ markup = {
+ "inline_keyboard": [[
+ {"text": "詳情", "callback_data": "detail:INC-20260716-TEST"},
+ {"text": "Runs", "url": "https://awoooi.wooo.work/zh-TW/awooop/runs"},
+ {"text": "Unsafe", "url": "http://192.0.2.1/internal"},
+ {"text": "External", "url": "https://example.com/not-canonical"},
+ ]]
+ }
+
+ filtered = filter_telegram_reply_markup(
+ markup,
+ interactive_buttons_ready=False,
+ )
+
+ assert _callback_rows(filtered) == [{
+ "text": "Runs",
+ "url": "https://awoooi.wooo.work/zh-TW/awooop/runs",
+ }]
+
+
+def test_gateway_render_gate_uses_live_ingress_status() -> None:
+ gateway = _ConfiguredGateway()
+ payload = {
+ "text": "incident",
+ "reply_markup": {
+ "inline_keyboard": [[
+ {"text": "詳情", "callback_data": "detail:INC-20260716-TEST"},
+ {"text": "Runs", "url": "https://awoooi.wooo.work/zh-TW/awooop/runs"},
+ ]]
+ },
+ }
+
+ guarded = gateway._apply_callback_ingress_render_gate(payload)
+
+ assert _callback_rows(guarded["reply_markup"]) == [{
+ "text": "Runs",
+ "url": "https://awoooi.wooo.work/zh-TW/awooop/runs",
+ }]
+
+
+def test_ready_markup_still_removes_unknown_ghost_callbacks() -> None:
+ markup = {
+ "inline_keyboard": [[
+ {"text": "詳情", "callback_data": "detail:INC-20260716-TEST"},
+ {"text": "Ghost view", "callback_data": "view:ERR:1:nonce"},
+ {"text": "Ghost scale", "callback_data": "scale:RES:1:nonce"},
+ {"text": "Runs", "url": "https://awoooi.wooo.work/zh-TW/awooop/runs"},
+ ]]
+ }
+
+ filtered = filter_telegram_reply_markup(
+ markup,
+ interactive_buttons_ready=True,
+ )
+ rendered = _callback_rows(filtered)
+
+ assert {button.get("callback_data") for button in rendered} == {
+ "detail:INC-20260716-TEST",
+ None,
+ }
+ assert all("view:" not in str(button) for button in rendered)
+ assert all("scale:" not in str(button) for button in rendered)
+
+
+def test_machine_registry_excludes_ghosts_and_describes_shadow_tune() -> None:
+ assert callback_action_is_registered("detail:INC-20260716-TEST") is True
+ assert callback_action_is_registered("view:ERR:1:nonce") is False
+ assert callback_action_is_registered("scale:RES:1:nonce") is False
+ tune = resolve_callback_action_contract("tune:APR-1:1:nonce")
+ assert tune is not None
+ assert tune.state == "shadow_candidate_only"
+
+ snapshot = telegram_button_registry_snapshot()
+ action_names = {row["action"] for row in snapshot["actions"]}
+ assert snapshot["schema_version"] == "telegram_button_action_registry_v1"
+ assert snapshot["unknown_action_policy"] == "do_not_render"
+ assert "view" not in action_names
+ assert "scale" not in action_names
+
+
+def test_every_yaml_action_has_a_renderable_registry_contract() -> None:
+ registry = load_action_registry()
+ assert registry
+
+ for action, spec in registry.items():
+ callback_data = (
+ f"{action}:APR-1:1:nonce"
+ if spec.callback_format == "nonce"
+ else f"{action}:INC-20260716-TEST"
+ )
+ contract = resolve_callback_action_contract(callback_data)
+ assert contract is not None, action
+ assert contract.handler == "callback_dispatcher.dispatch_action"
+ assert contract.executor.startswith("mcp:")
+
+
+def test_security_parser_accepts_every_registered_info_action() -> None:
+ gateway = _ConfiguredGateway()
+ info_actions = registered_info_callback_actions()
+ assert {"detail", "history", "reanalyze", "drift_view_page"} <= info_actions
+
+ for action in info_actions:
+ parsed = gateway._security.parse_callback_data(
+ f"{action}:INC-20260716-TEST"
+ )
+ assert parsed["action"] == action
+ assert parsed["is_info_action"] is True
+
+
+def test_legacy_builders_no_longer_emit_view_or_scale() -> None:
+ gateway = _ConfiguredGateway()
+ gateway._security = _Security()
+
+ sentry = _callback_rows(gateway._build_sentry_keyboard("ERR-1"))
+ resource = _callback_rows(gateway._build_resource_keyboard("RES-1"))
+
+ assert [button["callback_data"].split(":", 1)[0] for button in sentry] == [
+ "silence"
+ ]
+ assert [button["callback_data"].split(":", 1)[0] for button in resource] == [
+ "silence"
+ ]
+
+
+@pytest.mark.asyncio
+async def test_tune_button_truthfully_says_it_records_a_candidate() -> None:
+ gateway = _ConfiguredGateway()
+ gateway._security = _Security()
+
+ markup = await gateway._build_inline_keyboard(
+ approval_id="APR-1",
+ incident_id="INC-20260716-TEST",
+ include_auto_tuning=True,
+ auto_tuning_command="candidate-only",
+ )
+ labels = {button["text"] for button in _callback_rows(markup)}
+
+ assert "📝 記錄調優候選" in labels
+ assert "⚡ 執行自動調優" not in labels
+
+
+class _InfoCallbackGateway(_ConfiguredGateway):
+ def __init__(self) -> None:
+ super().__init__()
+ self.approval_execution_called = False
+
+ async def mirror_callback_query_received(self, **_kwargs) -> str:
+ return "receipt-1"
+
+ async def handle_callback(self, **_kwargs) -> dict:
+ return {
+ "success": True,
+ "info_action": True,
+ "action": "detail",
+ "approval_id": "INC-20260716-TEST",
+ }
+
+ async def _execute_approval_action(self, **_kwargs) -> None:
+ self.approval_execution_called = True
+
+
+@pytest.mark.asyncio
+async def test_polling_info_action_does_not_execute_approval_stage() -> None:
+ gateway = _InfoCallbackGateway()
+
+ await gateway._handle_callback_query(
+ 42,
+ {
+ "id": "callback-1",
+ "data": "detail:INC-20260716-TEST",
+ "from": {"id": 7, "username": "operator"},
+ "message": {
+ "message_id": 99,
+ "text": "incident",
+ "chat": {"id": -1001},
+ },
+ },
+ )
+
+ assert gateway.approval_execution_called is False
+ assert gateway.callback_ingress_status()["receipt_source"] == "api_long_polling"
+
+
+def test_webhook_route_has_one_canonical_owner() -> None:
+ route_paths = [route.path for route in telegram_api.router.routes]
+ route_paths.extend(route.path for route in telegram_webhook_api.router.routes)
+
+ assert route_paths.count("/telegram/webhook") == 1
+
+
+def test_production_webhook_without_secret_fails_closed(monkeypatch) -> None:
+ monkeypatch.setattr(
+ telegram_webhook_api,
+ "settings",
+ SimpleNamespace(ENVIRONMENT="prod", TELEGRAM_WEBHOOK_SECRET=""),
+ )
+
+ with pytest.raises(HTTPException) as exc_info:
+ telegram_webhook_api._verify_secret_token(None)
+
+ assert exc_info.value.status_code == 503
+
+
+class _CallbackRequest:
+ async def json(self) -> dict:
+ return {
+ "update_id": 77,
+ "callback_query": {
+ "id": "callback-77",
+ "data": "detail:INC-20260716-TEST",
+ "from": {"id": 7, "username": "operator"},
+ "message": {"message_id": 9, "chat": {"id": -1001}},
+ },
+ }
+
+
+class _CanonicalWebhookGateway:
+ def __init__(self) -> None:
+ self.receipts: list[dict] = []
+
+ async def _handle_callback_query(
+ self,
+ update_id: int,
+ callback_query: dict,
+ *,
+ ingress_source: str,
+ ) -> None:
+ self.receipts.append({
+ "update_id": update_id,
+ "callback_query": callback_query,
+ "ingress_source": ingress_source,
+ })
+
+
+@pytest.mark.asyncio
+async def test_canonical_webhook_forwards_callback_to_gateway_handler(monkeypatch) -> None:
+ gateway = _CanonicalWebhookGateway()
+ monkeypatch.setattr(
+ telegram_gateway_module,
+ "get_telegram_gateway",
+ lambda: gateway,
+ )
+
+ result = await telegram_webhook_api.telegram_webhook(_CallbackRequest())
+
+ assert result == {"ok": True}
+ assert len(gateway.receipts) == 1
+ assert gateway.receipts[0]["update_id"] == 77
+ assert gateway.receipts[0]["ingress_source"] == "canonical_webhook"
+
+
+@pytest.mark.asyncio
+async def test_health_is_degraded_when_callback_ingress_is_unverified(monkeypatch) -> None:
+ gateway = _ConfiguredGateway()
+ monkeypatch.setattr(telegram_api, "get_telegram_gateway", lambda: gateway)
+ monkeypatch.setattr(
+ telegram_api,
+ "settings",
+ SimpleNamespace(
+ OPENCLAW_TG_BOT_TOKEN="test-configured",
+ SRE_GROUP_CHAT_ID="test-configured",
+ OPENCLAW_TG_USER_WHITELIST=[],
+ ENVIRONMENT="test",
+ ),
+ )
+
+ health = await telegram_api.telegram_health()
+
+ assert health["status"] == "degraded_callback_ingress_unverified"
+ assert health["mode"] == "external_callback_forward_unverified"
+ assert health["interactive_buttons_ready"] is False
+ assert health["callback_ingress"]["fail_closed_reason"] == (
+ "callback_ingress_not_verified"
+ )