diff --git a/apps/api/src/api/v1/telegram_webhook.py b/apps/api/src/api/v1/telegram_webhook.py index 6e98c666..1e63fa3d 100644 --- a/apps/api/src/api/v1/telegram_webhook.py +++ b/apps/api/src/api/v1/telegram_webhook.py @@ -1,10 +1,11 @@ """ -Telegram Webhook Handler — ADR-094 -=================================== +Telegram Webhook Handler — ADR-094 / ADR-095 +============================================= 接收 Telegram Bot API 的 webhook push,支援 secret_token 驗證。 -WS4 Hermes NL 接入點框架(目前只記錄 update,不執行動作)。 +WS4 Hermes NL 接入:@mention 觸發 12-Agent 自然語言問答。 2026-04-24 Claude Sonnet 4.6 (ADR-093 WS3 / ADR-094) +2026-04-24 Claude Sonnet 4.6 (ADR-095 WS4 Hermes NL 接入) """ from __future__ import annotations @@ -70,7 +71,50 @@ async def telegram_webhook(request: Request) -> dict: update_id=body.get("update_id"), ) - # WS4: 在此呼叫 Hermes NL router - # hermes_router.dispatch(body) + # WS4: Hermes NL 接入(ADR-095) + # 只在 HERMES_NL_ENABLED=true 且 update_type=message 時處理 + if update_type == "message" and settings.HERMES_NL_ENABLED: + msg = body.get("message", {}) + text = msg.get("text", "") + chat_id = str(msg.get("chat", {}).get("id", "")) + from_user = msg.get("from", {}) + user_id = from_user.get("id", 0) + username = from_user.get("username", "") + + bot_username = settings.TELEGRAM_BOT_USERNAME + # 觸發條件:@mention 或私訊(私訊的 chat.type == "private") + chat_type = msg.get("chat", {}).get("type", "") + is_mention = f"@{bot_username}" in text + is_private = chat_type == "private" + message_id: int | None = msg.get("message_id") + + if (is_mention or is_private) and text.strip(): + clean_text = text.replace(f"@{bot_username}", "").strip() + if clean_text: + # 延遲 import 避免啟動時副作用(SDK / structlog 初始化) + from src.hermes.nl_gateway import process_nl_message + from src.services.telegram_gateway import get_telegram_gateway + + try: + reply = await process_nl_message( + clean_text, + chat_id=chat_id, + user_id=user_id, + username=username, + ) + gw = get_telegram_gateway() + # send_hermes_reply:長文純文字,reply-to 原始訊息 + await gw.send_hermes_reply( + text=reply, + chat_id=chat_id, + reply_to_message_id=message_id, + ) + except Exception as exc: + logger.error( + "telegram_webhook_hermes_error", + error=str(exc), + chat_id=chat_id, + user_id=user_id, + ) return {"ok": True} diff --git a/apps/api/src/core/config.py b/apps/api/src/core/config.py index ea6e7c31..96a441fe 100644 --- a/apps/api/src/core/config.py +++ b/apps/api/src/core/config.py @@ -461,6 +461,16 @@ class Settings(BaseSettings): default="", description="Telegram Webhook Secret Token(setWebhook 設定的同一值)", ) + # 2026-04-24 Claude Sonnet 4.6 (ADR-095 WS4): Hermes NL 自然語言閘道 + # false=不啟用(預設),true=啟用 @mention 問答(需 ANTHROPIC_API_KEY) + HERMES_NL_ENABLED: bool = Field( + default=False, + description="Hermes NL 對話功能開關(ADR-095)", + ) + TELEGRAM_BOT_USERNAME: str = Field( + default="tsenyangbot", + description="Telegram Bot username(不含 @),用於 @mention 識別", + ) WEBHOOK_NONCE_TTL: int = Field( default=300, description="Nonce TTL in seconds for replay attack prevention", diff --git a/apps/api/src/hermes/__init__.py b/apps/api/src/hermes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/apps/api/src/hermes/agent_loader.py b/apps/api/src/hermes/agent_loader.py new file mode 100644 index 00000000..3f550eea --- /dev/null +++ b/apps/api/src/hermes/agent_loader.py @@ -0,0 +1,37 @@ +"""載入 .claude/agents/*.md 並解析 system prompt(ADR-095) + +2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL) +""" +from __future__ import annotations +import pathlib +from functools import lru_cache + +_AGENTS_DIR = pathlib.Path("/Users/ogt/awoooi/.claude/agents") + + +def _parse_agent_md(path: pathlib.Path) -> str: + """去除 YAML frontmatter,回傳 body 作為 system prompt""" + text = path.read_text(encoding="utf-8") + # --- frontmatter --- + if text.startswith("---"): + end = text.find("---", 3) + if end != -1: + return text[end + 3:].strip() + return text.strip() + + +@lru_cache(maxsize=None) +def get_agent_system_prompt(agent_name: str) -> str | None: + """ + 回傳指定 agent 的 system prompt。 + 若 .md 不存在回傳 None(caller 決定是否 fallback)。 + """ + path = _AGENTS_DIR / f"{agent_name}.md" + if not path.exists(): + return None + return _parse_agent_md(path) + + +def list_available_agents() -> list[str]: + """回傳目前存在的 agent 名稱列表(無副檔名)""" + return [p.stem for p in sorted(_AGENTS_DIR.glob("*.md"))] diff --git a/apps/api/src/hermes/display_names.py b/apps/api/src/hermes/display_names.py new file mode 100644 index 00000000..0ded5c2e --- /dev/null +++ b/apps/api/src/hermes/display_names.py @@ -0,0 +1,43 @@ +"""ADR-095 12-Agent 視覺分派對照表 + +2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL) +""" +from __future__ import annotations +from dataclasses import dataclass + + +@dataclass(frozen=True) +class AgentVisual: + emoji: str + hashtag: str + handle: str # 文字 prefix,非真 bot + short_name: str + + +# 鍵值 = .claude/agents/{key}.md 的檔名(不含副檔名) +AGENT_VISUALS: dict[str, AgentVisual] = { + "critic": AgentVisual("🔍", "#審查", "@hermes-critic", "找碴專家"), + "vuln-verifier": AgentVisual("🎯", "#漏洞驗證", "@hermes-verifier", "漏洞驗證官"), + "debugger": AgentVisual("🐛", "#除錯", "@hermes-debugger", "除錯偵探"), + "db-expert": AgentVisual("💾", "#資料庫", "@hermes-db", "DB 軍師"), + "planner": AgentVisual("📋", "#拆解", "@hermes-planner", "任務拆解官"), + "fullstack-engineer": AgentVisual("🛠️", "#工程", "@hermes-engineer", "全端工程師"), + "frontend-designer": AgentVisual("🎨", "#設計", "@hermes-designer", "前端設計師"), + "refactor-specialist": AgentVisual("♻️", "#重構", "@hermes-refactor", "重構專家"), + "migration-engineer": AgentVisual("🚚", "#升級", "@hermes-migration", "升級工程師"), + "onboarder": AgentVisual("🗺️", "#導覽", "@hermes-onboarder", "領航員"), + "tool-expert": AgentVisual("🧰", "#工具", "@hermes-tools", "工具專家"), + "web-researcher": AgentVisual("📚", "#文檔", "@hermes-web", "文獻研究員"), +} + +DEFAULT_AGENT = "debugger" # Layer 3 fallback(最常見「X 為什麼壞了」) + + +def get_visual(agent_name: str) -> AgentVisual: + return AGENT_VISUALS.get(agent_name, AGENT_VISUALS[DEFAULT_AGENT]) + + +def format_response_header(agent_name: str) -> str: + """回傳 Telegram 訊息的 agent 識別前綴,格式: emoji 短稱 handle hashtag""" + v = get_visual(agent_name) + return f"{v.emoji} **{v.short_name}** `{v.handle}` {v.hashtag}\n\n" diff --git a/apps/api/src/hermes/nl_gateway.py b/apps/api/src/hermes/nl_gateway.py new file mode 100644 index 00000000..3541d600 --- /dev/null +++ b/apps/api/src/hermes/nl_gateway.py @@ -0,0 +1,133 @@ +"""Hermes 自然語言閘道 — ADR-094 + +Layer 1 意圖路由(關鍵字正則)→ Claude Agent SDK 呼叫 → Telegram 格式化輸出。 + +2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL) +""" +from __future__ import annotations +import re + +import structlog +from claude_agent_sdk import query, ClaudeAgentOptions +from claude_agent_sdk.types import ResultMessage + +from src.hermes.agent_loader import get_agent_system_prompt +from src.hermes.display_names import DEFAULT_AGENT, format_response_header +from src.hermes.safety_hooks import is_dangerous_input, is_mutate_intent + +logger = structlog.get_logger(__name__) + +# ───────────────────────────────────────────────────────────────────────────── +# Layer 1 意圖路由(關鍵字正則,<10ms) +# ───────────────────────────────────────────────────────────────────────────── +_ROUTING_RULES: list[tuple[re.Pattern, str]] = [ + (re.compile(r"(資料庫|postgres|sql|index|query|migration|schema)", re.IGNORECASE), "db-expert"), + (re.compile(r"(漏洞|CVE|injection|XSS|CSRF|security|安全)", re.IGNORECASE), "vuln-verifier"), + (re.compile(r"(bug|crash|error|exception|fail|失敗|崩潰|為什麼壞|不通)", re.IGNORECASE), "debugger"), + (re.compile(r"(重構|refactor|clean|重寫)", re.IGNORECASE), "refactor-specialist"), + (re.compile(r"(升級|upgrade|migration|migrate|版本)", re.IGNORECASE), "migration-engineer"), + (re.compile(r"(設計|UI|frontend|頁面|按鈕|樣式)", re.IGNORECASE), "frontend-designer"), + (re.compile(r"(工具|tool|hook|MCP|plugin)", re.IGNORECASE), "tool-expert"), + (re.compile(r"(文件|document|官方|API spec|how to)", re.IGNORECASE), "web-researcher"), + (re.compile(r"(導覽|介紹|架構|codebase|overview)", re.IGNORECASE), "onboarder"), + (re.compile(r"(拆解|任務|plan|規劃)", re.IGNORECASE), "planner"), + (re.compile(r"(審查|review|code review|找問題)", re.IGNORECASE), "critic"), + (re.compile(r"(實作|implement|develop|功能|feature)", re.IGNORECASE), "fullstack-engineer"), +] + +_HERMES_BUDGET_USD = 0.05 + + +def _route_intent_layer1(text: str) -> str: + """Layer 1: 關鍵字正則路由,回傳 agent 名稱""" + for pattern, agent in _ROUTING_RULES: + if pattern.search(text): + return agent + return DEFAULT_AGENT + + +async def process_nl_message( + user_message: str, + *, + chat_id: str, + user_id: int, + username: str = "", +) -> str: + """ + 處理 NL 訊息,回傳 Telegram 格式的回覆文字。 + + 流程: + 1. 安全守門(DENY + MUTATE) + 2. Layer 1 關鍵字路由 → agent_name + 3. 讀取 agent system prompt(.claude/agents/*.md) + 4. 呼叫 Claude Agent SDK query() + 5. 格式化為 Telegram MarkdownV2 訊息 + """ + # 安全守門 + if is_dangerous_input(user_message): + logger.warning( + "hermes_nl_dangerous_input", + user_id=user_id, + chat_id=chat_id, + preview=user_message[:80], + ) + return "⛔ 偵測到危險指令,拒絕處理。" + + if is_mutate_intent(user_message): + return ( + "⚠️ 此操作涉及變更,需透過正式審批流程執行。\n" + "請在 Telegram 告警卡片上操作,或聯繫值班 SRE。" + ) + + # Layer 1 意圖路由 + agent_name = _route_intent_layer1(user_message) + + # 確認 agent 存在,否則 fallback + system_prompt = get_agent_system_prompt(agent_name) + if system_prompt is None: + logger.warning( + "hermes_nl_agent_not_found", + agent=agent_name, + fallback=DEFAULT_AGENT, + ) + agent_name = DEFAULT_AGENT + system_prompt = get_agent_system_prompt(agent_name) or "" + + logger.info( + "hermes_nl_dispatch", + agent=agent_name, + user_id=user_id, + chat_id=chat_id, + username=username, + ) + + # 呼叫 Claude Agent SDK + try: + options = ClaudeAgentOptions( + system_prompt=system_prompt, + max_turns=3, + permission_mode="dontAsk", + max_budget_usd=_HERMES_BUDGET_USD, + ) + result_text = "" + async for event in query(prompt=user_message, options=options): + if isinstance(event, ResultMessage): + result_text = getattr(event, "result", "") or "" + break + + if not result_text: + result_text = "_Agent 回應為空,請稍後再試。_" + + except Exception as exc: + logger.error( + "hermes_nl_sdk_error", + error=str(exc), + agent=agent_name, + exc_type=type(exc).__name__, + ) + result_text = f"_Hermes 暫時無法連線({type(exc).__name__}),請稍後再試。_" + + header = format_response_header(agent_name) + # Telegram 訊息上限 4096 字元,超過截斷 + body = result_text[:3800] + return f"{header}{body}" diff --git a/apps/api/src/hermes/safety_hooks.py b/apps/api/src/hermes/safety_hooks.py new file mode 100644 index 00000000..c5ac240e --- /dev/null +++ b/apps/api/src/hermes/safety_hooks.py @@ -0,0 +1,63 @@ +"""Hermes NL 輸入安全守門 — Python 重做 awoooi-guard.js(ADR-094) + +從 .claude/hooks/awoooi-guard.js HARD BLOCK 規則移植, +涵蓋 K8s 生產刪除、docker volume 摧毀、force push、DROP TABLE 等。 + +2026-04-24 Claude Sonnet 4.6 (WS4 Hermes NL) +""" +from __future__ import annotations +import re + +# ───────────────────────────────────────────────────────────────────────────── +# DENY 正則:完整移植自 awoooi-guard.js HARD BLOCK 段落 +# ───────────────────────────────────────────────────────────────────────────── +_DENY_PATTERNS: list[re.Pattern] = [ + # 基本破壞性指令 + re.compile(r"rm\s+-rf", re.IGNORECASE), + re.compile(r"git\s+push\s+.*--force", re.IGNORECASE), + re.compile(r"DROP\s+TABLE", re.IGNORECASE), + re.compile(r"truncate\s+table", re.IGNORECASE), + re.compile(r"kubectl\s+delete\s+namespace", re.IGNORECASE), + re.compile(r"systemctl\s+(stop|disable)\s+", re.IGNORECASE), + re.compile(r"chmod\s+777", re.IGNORECASE), + re.compile(r"curl.*(sh|bash)\s*\|", re.IGNORECASE), + re.compile(r">\s*/etc/", re.IGNORECASE), + # K8s 生產命名空間(移植自 awoooi-guard.js HARD BLOCK) + re.compile(r"kubectl.*delete.*namespace.*awoooi-prod", re.IGNORECASE), + # K8s 生產 PVC/Secret 強制刪除 + re.compile(r"kubectl.*delete.*(pvc|secret).*-n.*awoooi-prod", re.IGNORECASE), + re.compile(r"kubectl.*-n.*awoooi-prod.*delete.*(pvc|secret)", re.IGNORECASE), + # docker compose down -v(摧毀 volume) + re.compile(r"docker[\s-]?compose.*down.*(-v\b|--volumes)", re.IGNORECASE), + # docker system prune -f + re.compile(r"docker\s+system\s+prune.*(-f|--force)", re.IGNORECASE), + # Telegram logOut / deleteWebhook(先停後換原則) + re.compile(r"api\.telegram\.org/bot[^/]+/(logOut|deleteWebhook)", re.IGNORECASE), + # psql DROP TABLE/DATABASE(非 test/dev 環境) + re.compile(r"psql.*-c.*DROP\s+(TABLE|DATABASE|SCHEMA)", re.IGNORECASE), + # force push 到 gitea main + re.compile(r"git push.*(--force|-f).*gitea.*main", re.IGNORECASE), + re.compile(r"git push.*gitea.*main.*(--force|-f)", re.IGNORECASE), + # DROP DATABASE 直接語句 + re.compile(r"DROP\s+DATABASE", re.IGNORECASE), + # 清除 /dev/null 覆蓋系統檔案 + re.compile(r">\s*/dev/(mem|kmem|port)", re.IGNORECASE), +] + +# ───────────────────────────────────────────────────────────────────────────── +# MUTATE 正則:變更意圖偵測(需走審批流) +# ───────────────────────────────────────────────────────────────────────────── +# 只允許 query/describe/summarize 類動作(mutate/deploy/approve 走審批流) +_MUTATE_PATTERNS: list[re.Pattern] = [ + re.compile(r"\b(deploy|apply|create|delete|rollout|approve|execute)\b", re.IGNORECASE), +] + + +def is_dangerous_input(text: str) -> bool: + """True → 拒絕,回 ⛔""" + return any(p.search(text) for p in _DENY_PATTERNS) + + +def is_mutate_intent(text: str) -> bool: + """True → 需要走 ApprovalRecord 二次確認""" + return any(p.search(text) for p in _MUTATE_PATTERNS) diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index ddf5dbf8..e2284671 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -4606,6 +4606,32 @@ class TelegramGateway: return await self._send_request("sendMessage", payload) + # ========================================================================= + # 2026-04-24 Claude Sonnet 4.6 (ADR-095 WS4): Hermes NL 回覆 + # ========================================================================= + + async def send_hermes_reply( + self, + text: str, + chat_id: str | int, + reply_to_message_id: int | None = None, + ) -> dict: + """ + 傳送 Hermes NL 回覆(長文,最多 4096 字元,純文字模式)。 + + Args: + text: 回覆內容(由 nl_gateway 已截斷至 4000 字以內) + chat_id: 目標 chat ID + reply_to_message_id: 回覆哪則訊息(可選) + """ + payload: dict = { + "chat_id": chat_id, + "text": text[:4096], + } + if reply_to_message_id: + payload["reply_to_message_id"] = reply_to_message_id + return await self._send_request("sendMessage", payload) + # ========================================================================= # 2026-04-03 ogt: SRE 戰情室群組三頭政治 (Triumvirate) — ADR-053 # @tsenyangbot 發告警卡片到群組,OpenClaw/NemoClaw Bot 各自回覆分析