""" Telegram Gateway - OpenClaw 行動戰情室 + SignOz 整合 ==================================================== Phase 5.4.3 & 5.4.4: Telegram 推送與簽核接收 統帥校正: SignOz 為唯一全能視力中心 Features: - 推送待簽核卡片到 Telegram (含 SignOz 指標) - 動態 SignOz Trace URL (告警前後 5 分鐘) - 自動調優按鈕 (Shadow Mode: 僅日誌輸出) - 接收統帥簽核回調 - SOUL.md 訊息壓縮原則 100% 遵守 SOUL.md 鐵律 (4.1 Telegram 訊息壓縮原則): - 狀態標籤: 20 字元 - 資源名稱: 50 字元 - 根因摘要: 100 字元 - 建議行動: 50 字元 - 總長度: 800 字元 (v7.0 擴展以容納 SignOz 區塊) 修復紀錄: - 2026-03-26 Claude Code: 修復 HTML 解析錯誤 (Can't parse entities) """ import asyncio import hashlib import html import json import os import re from dataclasses import dataclass, field from datetime import UTC, datetime from uuid import NAMESPACE_URL, UUID, uuid5 import httpx import structlog from opentelemetry import trace from src.core.config import settings from src.core.redis_client import get_redis from src.services.awooop_ansible_audit_service import summarize_ansible_execution from src.services.awooop_deeplinks import ( AWOOOP_WEB_BASE_URL, incident_alerts_url, incident_runs_url, incident_truth_chain_button_row, incident_truth_chain_reply_markup, work_item_url, ) from src.services.approval_action_classifier import ( is_executable_repair_approval_action, is_no_action_approval_action, ) from src.services.chat_manager import get_chat_manager from src.services.operator_outcome import build_operator_outcome, normalize_operator_outcome from src.services.security_interceptor import ( NonceReplayError, UserNotWhitelistedError, get_security_interceptor, ) # ============================================================================= # Snooze/Silence Redis Keys (2026-03-27 P1 優化) # ============================================================================= SNOOZE_KEY_PREFIX = "telegram_snooze:" # {approval_id} -> 稍後提醒 SILENCE_KEY_PREFIX = "telegram_silence:" # {resource_name} -> 靜默 SNOOZE_TTL_SECONDS = 30 * 60 # 30 分鐘 SILENCE_TTL_SECONDS = 60 * 60 # 1 小時 INCIDENT_UPDATE_DEDUP_PREFIX = "awoooi:tg_update_dedup:" # {incident_id}:{status_hash} INCIDENT_UPDATE_DEDUP_TTL_SECONDS = 5 * 60 # 5 分鐘內相同狀態不重複洗版 INCIDENT_UPDATE_GLOBAL_FAILURE_DEDUP_PREFIX = "awoooi:tg_update_global_failure_dedup:" INCIDENT_UPDATE_GLOBAL_FAILURE_DEDUP_TTL_SECONDS = 10 * 60 # 相同失敗摘要跨 incident 10 分鐘只推一次 GROUPED_ALERT_DIGEST_DEDUP_PREFIX = "awoooi:tg_group_digest:" # {group_key} GROUPED_ALERT_DIGEST_DEDUP_TTL_SECONDS = 5 * 60 # 同一告警群組 5 分鐘只推一則 digest _HEARTBEAT_WARNING_FINGERPRINT_RULES: tuple[tuple[re.Pattern[str], str], ...] = ( # 2026-06-24 Codex + ogt: Telegram heartbeat dedupe must track the # actionable condition, not volatile probe details. HTTP status, timeout, # latency and count text can vary every 30 minutes while the operator # action stays exactly the same, which caused repeated "heartbeat" noise. (re.compile(r"^(Ollama\s+[^:]+)\s+異常:.*$"), r"\1 異常"), (re.compile(r"^(.+?)\s+服務異常:.*$"), r"\1 服務異常"), (re.compile(r"^(MCP\s+[^:]+):.*$"), r"\1"), (re.compile(r"^(ArgoCD):.*$"), r"\1"), (re.compile(r"^(PostgreSQL):.*$"), r"\1"), (re.compile(r"^(Redis):.*$"), r"\1"), (re.compile(r"^KM\s+向量化率偏低:.*$"), "KM 向量化率偏低"), (re.compile(r"^系統沉默\s+.*$"), "系統沉默(無學習活動)"), (re.compile(r"^待人工審核\s+.*$"), "待人工審核積壓"), ) def _normalize_heartbeat_warning_for_fingerprint(warning: str) -> str: """Return the stable actionable identity used for heartbeat dedupe.""" normalized = " ".join(str(warning).split()) for pattern, replacement in _HEARTBEAT_WARNING_FINGERPRINT_RULES: replaced = pattern.sub(replacement, normalized) if replaced != normalized: return replaced return normalized def _heartbeat_warnings_hash(warnings: list[str]) -> str: """Hash heartbeat warnings after stripping volatile probe details.""" warnings_str = "|".join( sorted(_normalize_heartbeat_warning_for_fingerprint(item) for item in warnings) ) return hashlib.md5(warnings_str.encode()).hexdigest()[:12] # 2026-04-01 Claude Code: Long Polling 分散式 Leader Election # 防止多 Pod 同時 getUpdates → 409 Conflict 互搶問題 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 嘗試接管 logger = structlog.get_logger(__name__) _TELEGRAM_BOT_URL_RE = re.compile(r"(api\.telegram\.org/bot)[^/\s]+") _INCIDENT_ID_RE = re.compile(r"\bINC-\d{8}-[A-Z0-9]{4,}\b") _META_EVENT_ID_RE = re.compile(r"\bMETA-\d{14}\b") _AI_ADVISORY_CALLBACK_RE = re.compile( r"^ai_advisory_(?:handled|snooze|view|produce_cmd):([^:]+):(.+)$" ) _CODE_REF_RE = re.compile(r"([0-9a-f]{7,12})", re.IGNORECASE) _TELEGRAM_HTML_CHUNK_LIMIT = 3600 _AWOOOP_SOURCE_ENVELOPE_EXTRA_KEY = "_awooop_source_envelope_extra" _HOST_RESOURCE_ALERT_HEADER_RE = re.compile( r"\b(?:WARN|CRIT|INFO)\s+" r"(?P[A-Za-z0-9_.-]+).*?" r"CPU 警告:\s*used=(?P[0-9.]+)%\s+load=(?P[0-9.]+)", re.DOTALL, ) _HOST_RESOURCE_TARGET_RE = re.compile( r"\b(?:WARN|CRIT|INFO)\s+(?P[A-Za-z0-9_.-]+)\b" ) _HOST_RESOURCE_ALERTNAME_RE = re.compile(r"\balertname\s*=\s*\"?(?P[A-Za-z0-9_.:-]+)\"?") _HOST_RESOURCE_HOST_LABEL_RE = re.compile(r"\bhost\s*=\s*\"?(?P[A-Za-z0-9_.:-]+)\"?") _HOST_RESOURCE_RULE_LABEL_RE = re.compile(r"\brule\s*=\s*\"?(?P[A-Za-z0-9_.:-]+)\"?") _HOST_PROCESS_LINE_RE = re.compile( r"^\s*(?P\S+)\s+" r"(?P\d+)\s+" r"(?P[0-9.]+)\s+" r"(?P[0-9.]+)\s+" r"(?:\S+\s+){6}" r"(?P.+?)\s*$" ) _PRIVATE_IP_RE = re.compile( r"\b(?:10|192\.168|172\.(?:1[6-9]|2\d|3[0-1]))(?:\.\d{1,3}){2}\b" ) _URL_RE = re.compile(r"https?://\S+") _ABSOLUTE_PATH_RE = re.compile(r"/(?:[\w@.+-]+/){2,}[\w@.+-]*") _SECRET_ASSIGNMENT_RE = re.compile( r"(?i)\b(token|secret|password|authorization|api[_-]?key|bearer)\b\s*[:=]\s*\S+" ) _BEARER_RE = re.compile(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]+") _AI_SIGNAL_TARGET_LABEL_RE = re.compile( r"\b(?:host|target|service|domain|agent\.name|node|route)\s*=\s*\"?(?P[A-Za-z0-9_.:-]+)\"?" ) _ALERT_CARD_CODE_VALUE_RE = re.compile( r"(?P