diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index eed64baa..0e338fd5 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -29,6 +29,7 @@ import os import re from dataclasses import dataclass from datetime import UTC, datetime +from uuid import NAMESPACE_URL, UUID, uuid5 import httpx import structlog @@ -79,6 +80,26 @@ def _is_noisy_failure_update(status_line: str) -> bool: or "AI 診斷工具失敗" in status_line ) + +def _legacy_outbound_run_id(chat_id: str, provider_message_id: str) -> UUID: + """Legacy Telegram 發送尚未有 run_id 時,產生穩定 soft run_id 供 Channel Hub 串接。""" + return uuid5(NAMESPACE_URL, f"awoooi:legacy-telegram:{chat_id}:{provider_message_id}") + + +def _infer_outbound_message_type(text: str, payload: dict) -> str: + """將既有 Telegram 訊息映射成 AwoooP outbound_message 的有限分類。""" + if "reply_to_message_id" in payload: + if "失敗" in text or "錯誤" in text or "FAILED" in text: + return "error" + return "final" + if payload.get("reply_markup"): + return "approval_request" + if "ACTION REQUIRED" in text or "待審" in text or "審批" in text: + return "approval_request" + if "失敗" in text or "錯誤" in text or "FAILED" in text: + return "error" + return "final" + # 2026-04-27 Claude Sonnet 4.6: B3 — LLM 動態 Telegram 按鈕 Feature Flag # true → 優先使用 ActionPlan.recommended_actions 動態生成按鈕 # false → 維持現有 callback_action_spec.yaml 路徑(預設,向下相容) @@ -1511,6 +1532,11 @@ class TelegramGateway: result_val = result.get("result") if isinstance(result_val, dict) and "message_id" in result_val: span.set_attribute("telegram.message_id", result_val["message_id"]) + await self._mirror_outbound_message( + method=method, + payload=payload, + provider_message_id=str(result_val["message_id"]), + ) span.set_status(trace.Status(trace.StatusCode.OK)) return result @@ -1541,6 +1567,52 @@ class TelegramGateway: ) raise TelegramGatewayError(safe_error) from None + async def _mirror_outbound_message( + self, + *, + method: str, + payload: dict, + provider_message_id: str, + ) -> None: + """將 legacy Telegram 出站訊息鏡像到 AwoooP,不改變實際發送行為。""" + if method != "sendMessage": + return + + chat_id = str(payload.get("chat_id") or "") + text = str(payload.get("text") or payload.get("caption") or "") + if not chat_id or not text: + return + + try: + from src.core.context import get_current_project_id + from src.db.base import get_db_context + from src.services.channel_hub import record_outbound_message + + project_id = get_current_project_id() or "awoooi" + run_id = _legacy_outbound_run_id(chat_id, provider_message_id) + async with get_db_context(project_id) as db: + await record_outbound_message( + db, + project_id=project_id, + run_id=run_id, + channel_type="telegram", + channel_chat_id=chat_id, + message_type=_infer_outbound_message_type(text, payload), + content=text, + provider_message_id=provider_message_id, + send_status="sent", + triggered_by_state="legacy_gateway", + is_shadow=False, + ) + except Exception as exc: + logger.warning( + "telegram_outbound_mirror_failed", + method=method, + chat_id=chat_id, + provider_message_id=provider_message_id, + error=str(exc), + ) + async def _build_inline_keyboard( self, approval_id: str, diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index d0119031..590d5ba1 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -231,6 +231,43 @@ async def test_append_incident_update_suppresses_duplicate_failure_across_incide ] +def test_outbound_message_type_inference(): + """Legacy Telegram 訊息 mirror 到 Channel Hub 時,必須映射成有限分類。""" + + assert ( + telegram_gateway_module._infer_outbound_message_type( + "ℹ️ ACTION REQUIRED | 低風險", + {"reply_markup": {"inline_keyboard": []}}, + ) + == "approval_request" + ) + assert ( + telegram_gateway_module._infer_outbound_message_type( + "🤖❌ [AUTO] AI 自動修復失敗", + {"reply_to_message_id": 123}, + ) + == "error" + ) + assert ( + telegram_gateway_module._infer_outbound_message_type( + "✅ 執行成功", + {"reply_to_message_id": 123}, + ) + == "final" + ) + + +def test_legacy_outbound_run_id_is_stable(): + """沒有正式 run_id 的 legacy Telegram 發送,要有穩定 soft run_id 供查詢串接。""" + + first = telegram_gateway_module._legacy_outbound_run_id("-1001", "42") + second = telegram_gateway_module._legacy_outbound_run_id("-1001", "42") + other = telegram_gateway_module._legacy_outbound_run_id("-1001", "43") + + assert first == second + assert first != other + + class TestSentryErrorMessage: """測試 Sentry 錯誤訊息""" diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 935352b1..88764078 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -4388,3 +4388,32 @@ HTTP: ``` 判讀:Telegram 失敗摘要跨 incident 去重已進正式 image;後續若仍看到洗版,下一步要查的是 firing alert fingerprint 聚合與 `conversation_event` / `outbound_message` 是否仍有 caller 繞過 `append_incident_update()`。 + +## 2026-05-07(台北)— Telegram 出站訊息鏡像到 AwoooP Outbound + +**觸發**:統帥指出 Telegram 訊息仍然太雜,難以分辨哪些是 AI 自動化修復、哪些是 AI 無法修復需人工接手;AwoooP 需要成為治理與操作層,而不是與 Telegram 分裂。 + +### 改動 + +- 在 legacy `TelegramGateway._send_request()` 成功執行 `sendMessage` 後,將出站訊息鏡像到 `awooop_outbound_message`。 +- 沒有正式 `run_id` 的 legacy 發送使用穩定 soft run id:`awoooi:legacy-telegram:{chat_id}:{provider_message_id}` 的 UUIDv5。 +- legacy 訊息先映射成有限分類:`approval_request`、`final`、`error`,供 AwoooP Run / Timeline 後續彙整。 +- 鏡像採 fail-open:DB / RLS / schema 或 Channel Hub 失敗時只寫 `telegram_outbound_mirror_failed`,不得影響 Telegram 原本送達。 + +### 驗證 + +```text +/Users/ogt/awoooi/apps/api/.venv/bin/python -m py_compile \ + apps/api/src/services/telegram_gateway.py \ + apps/api/tests/test_telegram_message_templates.py +# passed + +DATABASE_URL='postgresql+asyncpg://test:test@localhost:5432/test' \ + /Users/ogt/awoooi/apps/api/.venv/bin/python -m pytest \ + apps/api/tests/test_telegram_message_templates.py -q +# 22 passed +``` + +### 判讀 + +這一步是 mirror-first,不改 Telegram 實際發送、不切 Channel Hub 主路徑。下一步要補的是 direct Bot API caller 收斂與 firing alert fingerprint 聚合,讓戰情室只接收需要人注意的摘要,完整時間線回到 AwoooP。 diff --git a/docs/awooop/TELEGRAM-INCIDENT-NOTIFICATION-MODEL.md b/docs/awooop/TELEGRAM-INCIDENT-NOTIFICATION-MODEL.md index 34d64ce5..1107c0f4 100644 --- a/docs/awooop/TELEGRAM-INCIDENT-NOTIFICATION-MODEL.md +++ b/docs/awooop/TELEGRAM-INCIDENT-NOTIFICATION-MODEL.md @@ -38,6 +38,7 @@ Telegram 不應是完整執行日誌,也不應承載所有 AI 推理細節。T - `TelegramMessage` 主卡新增「處置狀態」。 - `append_incident_update()` 對同一 incident 的相同狀態做 5 分鐘 Redis 去重。 - `append_incident_update()` 對相同的「AI 自動修復失敗 / AI 診斷工具失敗」摘要增加 10 分鐘跨 incident 去重;每個 incident 仍會移除原卡危險按鈕,但 Telegram 不再重複 reply 同一個失敗摘要。 +- `TelegramGateway._send_request()` 對成功送出的 legacy `sendMessage` 增加 AwoooP `awooop_outbound_message` 鏡像。鏡像失敗只記錄 `telegram_outbound_mirror_failed`,不能影響 Telegram 正常送達。 - 既有 `詳情 / 重診 / 歷史` 按鈕保留,讓 Telegram 保持輕量,細節回到控制台。 ## 後續建議