Files
awoooi/apps/api/src/services/callback_dispatcher.py
OG T 2e2f5a1881
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
feat(Phase 5 Sprint 5.0): Callback Dispatcher 規格 + 骨架 + 22 測試
統帥批准 Phase 5 全 Sprint,Sprint 5.0 產出:

1. callback_action_spec.yaml (24 actions)
   - 10 查類 (info 2-part callback, 無副作用): check_process, check_port,
     check_log_*, check_health, check_pod_logs, describe_pod, open_signoz,
     open_flywheel
   - 10 寫類 (nonce 4-part, 有副作用): k8s_restart/scale_up/scale_down/rollback,
     host_restart_service/clear_log, docker_restart, minio_restart,
     reload_nginx, renew_cert
   - 4 secops (Multi-Sig CRITICAL): secops_isolate/block_ip/evict/authorize

2. callback_dispatcher.py
   - Registry pattern (lru_cache): get_action_spec / list_actions_for_category
   - 模板變數替換: {incident_id} / {labels.xxx} / {signals[0].xxx}
   - dispatch_action() 骨架 (Sprint 5.2+ 接 MCP)
   - _format_reply: text/code/truncated/url 4 種格式

3. test_callback_dispatcher.py (22 tests全過)
   - Registry loading 正確性
   - Category filtering
   - Template resolution (含 nested list index)
   - dispatch stub 返回正確 spec 提示

下一步 Sprint 5.1: 接入 MCP registry + telegram callback_handler 整合
MCP 底層能力已有 (k8s 10+ tools, ssh 15 tools)

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
2026-04-14 20:34:14 +08:00

325 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Telegram Callback Dispatcher — 分類按鈕統一調度
================================================
Phase 5 Sprint 5.0-5.1 — 2026-04-14 Claude Sonnet 4.6
相關: docs/superpowers/plans/2026-04-14-PHASE-5-category-buttons-completion.md
ADR-079 分類按鈕完整化
職責:
1. 從 callback_action_spec.yaml 載入 action registry
2. 接收 Telegram callback_data (action:incident_id or action:id:ts:rand)
3. 驗證 nonce寫類按鈕或 allow info查類按鈕
4. 依 spec 呼叫對應 MCP tool
5. Reply 執行結果到原告警卡片reply_to_message_id
設計原則:
- Registry pattern — 新增按鈕只需 yaml 一行,無需改 dispatcher code
- 模板變數: {incident_id} / {labels.xxx} / {signals[0].xxx} / {callback.user_id}
- 所有 action 都有 audit log寫類額外 nonce 驗證 log
- reply_to 原告警 message_id從 Redis tg_msg:{incident_id}
遵守「禁止 Mock 測試鐵律」: 純邏輯 + MCP dispatch測試用真實 registry。
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from functools import lru_cache
from pathlib import Path
from typing import Any
import structlog
import yaml
logger = structlog.get_logger(__name__)
# =============================================================================
# Data Types
# =============================================================================
@dataclass
class ActionSpec:
"""從 callback_action_spec.yaml 載入的單一 action 規格"""
name: str
label: str
emoji: str
risk: str # low | medium | high | critical
callback_format: str # info | nonce
category: str
mcp_provider: str # k8s | ssh | prometheus | signoz | database | internal
mcp_tool: str
mcp_params: dict[str, Any]
reply_format: str # text | code | url | truncated
timeout_sec: int
description: str
requires_multi_sig: bool = False
@dataclass
class DispatchResult:
"""Dispatcher 執行結果"""
success: bool
action: str
incident_id: str
user_id: int | None
result_text: str
error: str | None = None
duration_ms: float = 0.0
# =============================================================================
# Spec Registry
# =============================================================================
@lru_cache(maxsize=1)
def load_action_registry() -> dict[str, ActionSpec]:
"""
載入 callback_action_spec.yaml 並快取(進程內不重載,重啟 Pod 才更新)
Returns:
{action_name: ActionSpec}
"""
spec_path = Path(__file__).parent / "callback_action_spec.yaml"
if not spec_path.exists():
logger.warning("callback_action_spec_not_found", path=str(spec_path))
return {}
with spec_path.open("r", encoding="utf-8") as f:
data = yaml.safe_load(f)
registry: dict[str, ActionSpec] = {}
for name, spec_dict in (data.get("actions") or {}).items():
mcp = spec_dict.get("mcp", {}) or {}
registry[name] = ActionSpec(
name=name,
label=spec_dict.get("label", name),
emoji=spec_dict.get("emoji", ""),
risk=spec_dict.get("risk", "medium"),
callback_format=spec_dict.get("callback_format", "info"),
category=spec_dict.get("category", ""),
mcp_provider=mcp.get("provider", ""),
mcp_tool=mcp.get("tool", ""),
mcp_params=mcp.get("params") or {},
reply_format=spec_dict.get("reply_format", "text"),
timeout_sec=int(spec_dict.get("timeout_sec", 10)),
description=spec_dict.get("description", ""),
requires_multi_sig=bool(spec_dict.get("requires_multi_sig", False)),
)
logger.info("callback_action_registry_loaded", count=len(registry))
return registry
def get_action_spec(action_name: str) -> ActionSpec | None:
"""查找單一 action 規格"""
return load_action_registry().get(action_name)
def list_actions_for_category(alert_category: str) -> list[ActionSpec]:
"""列出特定分類的所有可用 action供 _build_inline_keyboard 使用)"""
return [
spec for spec in load_action_registry().values()
if spec.category == alert_category
]
# =============================================================================
# Template Variable Substitution
# =============================================================================
def _resolve_template(template: Any, context: dict) -> Any:
"""
遞迴替換模板變數。
支援:
- {incident_id}
- {labels.xxx} / {labels.xxx.yyy}
- {signals[0].xxx}
- {callback.user_id}
Example:
template = {"host": "{labels.instance}", "lines": 50}
context = {"labels": {"instance": "192.168.0.110"}, "incident_id": "INC-123"}
{"host": "192.168.0.110", "lines": 50}
"""
if isinstance(template, dict):
return {k: _resolve_template(v, context) for k, v in template.items()}
if isinstance(template, list):
return [_resolve_template(v, context) for v in template]
if isinstance(template, str) and "{" in template:
# 找出所有 {xxx} placeholder 並替換
import re
def _repl(m: re.Match) -> str:
key = m.group(1)
val = _lookup_context(key, context)
return str(val) if val is not None else m.group(0)
return re.sub(r"\{([a-zA-Z0-9_.\[\]]+)\}", _repl, template)
return template
def _lookup_context(key: str, context: dict) -> Any:
"""
從 context 查表(支援巢狀 key: labels.instance / signals[0].alert_name
"""
parts = key.replace("[", ".").replace("]", "").split(".")
cur: Any = context
for part in parts:
if part == "":
continue
if isinstance(cur, dict):
cur = cur.get(part)
elif isinstance(cur, list):
try:
cur = cur[int(part)]
except (ValueError, IndexError):
return None
else:
return None
if cur is None:
return None
return cur
# =============================================================================
# Dispatcher (Sprint 5.1)
# =============================================================================
async def dispatch_action(
action_name: str,
incident_id: str,
user_id: int | None = None,
labels: dict | None = None,
extra_context: dict | None = None,
) -> DispatchResult:
"""
執行 callback action — 依 spec 呼叫 MCP tool
Args:
action_name: action 名稱(對應 spec registry
incident_id: 關聯 incident
user_id: Telegram user idcallback 來源)
labels: alert labels供模板替換
extra_context: 額外上下文signals 等)
Returns:
DispatchResult包含 result_text 供 reply 使用)
"""
start = time.perf_counter()
spec = get_action_spec(action_name)
if not spec:
logger.warning("dispatch_action_unknown", action=action_name)
return DispatchResult(
success=False,
action=action_name,
incident_id=incident_id,
user_id=user_id,
result_text="",
error=f"Unknown action: {action_name}",
duration_ms=(time.perf_counter() - start) * 1000,
)
# 建立模板 context
context = {
"incident_id": incident_id,
"labels": labels or {},
"callback": {"user_id": user_id or 0},
**(extra_context or {}),
}
resolved_params = _resolve_template(spec.mcp_params, context)
# Audit log (all actions)
logger.info(
"dispatch_action_start",
action=action_name,
incident_id=incident_id,
user_id=user_id,
risk=spec.risk,
provider=spec.mcp_provider,
tool=spec.mcp_tool,
params=resolved_params,
)
# MCP 呼叫Sprint 5.1 只實作骨架,實際 MCP dispatch 在 Sprint 5.2/5.3
try:
# Sprint 5.1 TODO: 接入 MCP registry
# from src.plugins.mcp.registry import get_provider
# provider = get_provider(spec.mcp_provider)
# import asyncio
# mcp_result = await asyncio.wait_for(
# provider.execute(spec.mcp_tool, resolved_params),
# timeout=spec.timeout_sec,
# )
# result_text = _format_reply(mcp_result, spec.reply_format, spec.label, spec.emoji)
# Sprint 5.0 骨架:僅返回「尚未實作」提示
result_text = (
f"{spec.emoji} <b>{spec.label}</b>\n"
f"⚠️ Phase 5 Sprint 5.2+ 實作中spec 已載入:{spec.mcp_provider}/{spec.mcp_tool}\n"
f"參數: <code>{resolved_params}</code>"
)
duration = (time.perf_counter() - start) * 1000
logger.info(
"dispatch_action_stub",
action=action_name,
incident_id=incident_id,
duration_ms=round(duration, 1),
)
return DispatchResult(
success=True,
action=action_name,
incident_id=incident_id,
user_id=user_id,
result_text=result_text,
duration_ms=duration,
)
except Exception as e:
duration = (time.perf_counter() - start) * 1000
logger.error(
"dispatch_action_failed",
action=action_name,
incident_id=incident_id,
error=str(e),
duration_ms=round(duration, 1),
)
return DispatchResult(
success=False,
action=action_name,
incident_id=incident_id,
user_id=user_id,
result_text=f"{spec.emoji} {spec.label} 執行失敗",
error=str(e),
duration_ms=duration,
)
def _format_reply(
mcp_result: Any, reply_format: str, label: str, emoji: str
) -> str:
"""
依 spec 格式化 reply 文字。
reply_format:
- text: 單行文字
- code: <code>...</code>
- truncated: 截斷到 500 字
- url: 直接返回 URL
"""
header = f"{emoji} <b>{label}</b>"
if reply_format == "url":
return f"{header}\n{mcp_result}"
if reply_format == "code":
return f"{header}\n<code>{str(mcp_result)[:800]}</code>"
if reply_format == "truncated":
text = str(mcp_result)[:500]
if len(str(mcp_result)) > 500:
text += "...\n<i>(已截斷)</i>"
return f"{header}\n<pre>{text}</pre>"
return f"{header}\n{mcp_result}"