""" 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 id(callback 來源) 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} {spec.label}\n" f"⚠️ Phase 5 Sprint 5.2+ 實作中(spec 已載入:{spec.mcp_provider}/{spec.mcp_tool})\n" f"參數: {resolved_params}" ) 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: ... - truncated: 截斷到 500 字 - url: 直接返回 URL """ header = f"{emoji} {label}" if reply_format == "url": return f"{header}\n{mcp_result}" if reply_format == "code": return f"{header}\n{str(mcp_result)[:800]}" if reply_format == "truncated": text = str(mcp_result)[:500] if len(str(mcp_result)) > 500: text += "...\n(已截斷)" return f"{header}\n
{text}
" return f"{header}\n{mcp_result}"