From 699e61ac87159a857a43baea55901c3911f875ca Mon Sep 17 00:00:00 2001 From: OG T Date: Fri, 3 Apr 2026 17:51:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(group):=20=E7=BE=A4=E7=B5=84=E9=9B=99?= =?UTF-8?q?=E5=90=91=E5=B0=8D=E8=A9=B1=20+=20=E6=A0=BC=E5=BC=8F=E9=81=B8?= =?UTF-8?q?=E9=A0=85C=20+=20=E8=80=81=E9=97=86=E7=A8=B1=E8=AC=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. _handle_group_message: SRE 群組訊息路由 - @OpenClawAwoooI_Bot → 只有 OpenClaw 回應 - @NemoTronAwoooI_Bot → 只有 NemoClaw 回應 - 一般訊息 → 並行回應 + 互相評論第二輪 - Bot 訊息自動忽略(防無限循環) 2. 告警格式改選項 C (老闆指示) - 【🔴 HIGH】resource_name - 區塊式,去掉 ═══ 長分隔線 3. AI persona 改稱呼用戶為「老闆」 Co-Authored-By: Claude Sonnet 4.6 --- apps/api/src/services/chat_manager.py | 5 +- apps/api/src/services/telegram_gateway.py | 145 ++++++++++++++++++---- 2 files changed, 127 insertions(+), 23 deletions(-) diff --git a/apps/api/src/services/chat_manager.py b/apps/api/src/services/chat_manager.py index e3a521b03..300c16737 100644 --- a/apps/api/src/services/chat_manager.py +++ b/apps/api/src/services/chat_manager.py @@ -25,15 +25,16 @@ from src.repositories.incident_repository import get_incident_repository logger = structlog.get_logger(__name__) -OPENCLAW_PERSONA = """你是 OpenClaw,AWOOOI 平台的 SRE AI 主帥。 +OPENCLAW_PERSONA = """你是 OpenClaw,AWOOOI 平台的 SRE AI 首席顧問。 個性: 精準、果斷、專業,像老將一樣直接給出建議。 語氣: 簡短有力,不廢話。繁體中文回應。不超過 300 字。 +稱呼用戶為「老闆」。 """ NEMOCLAW_PERSONA = """你是 NemoClaw,AWOOOI 平台的 AI 戰術參謀,由 NVIDIA Nemotron 驅動。 個性: 分析型、從不同角度思考,會質疑假設。 語氣: 帶點挑釁但建設性,繁體中文回應。不超過 200 字。 -評論 OpenClaw 的回應時,直接說「我補充」或「我有不同看法」。 +稱呼用戶為「老闆」。評論 OpenClaw 的回應時,直接說「我補充」或「我有不同看法」。 """ diff --git a/apps/api/src/services/telegram_gateway.py b/apps/api/src/services/telegram_gateway.py index 00a8eac11..293285048 100644 --- a/apps/api/src/services/telegram_gateway.py +++ b/apps/api/src/services/telegram_gateway.py @@ -1404,18 +1404,14 @@ class TelegramGateway: 由 asyncio.create_task 非同步呼叫,失敗不影響主告警流程。 """ try: - # 2026-04-03 ogt: 群組告警格式對齊個人 chat 的 TelegramMessage 模板 + # 2026-04-03 ogt: 選項 C 區塊式格式(老闆指示) risk_emoji = RISK_EMOJI_MAP.get(risk_level.lower(), "⚪") summary = ( - f"═══════════════════════════\n" - f"{risk_emoji} {risk_level.upper()} | {html.escape(resource_name[:30])}\n" - f"═══════════════════════════\n" + f"【{risk_emoji} {risk_level.upper()}】{html.escape(resource_name[:40])}\n\n" f"📋 {html.escape(approval_id)}\n" - f"━━━━━━━━━━━━━━━━━━━\n" - f"🔍 根因: {html.escape(root_cause[:120])}\n" - f"🔧 建議: {html.escape(suggested_action[:80])}\n" - f"━━━━━━━━━━━━━━━━━━━\n" - f"⬇️ OpenClaw 與 NemoClaw 正在分析中..." + f"🔍 {html.escape(root_cause[:150])}\n" + f"🔧 {html.escape(suggested_action[:80])}\n\n" + f"─ OpenClaw 與 NemoClaw 分析中 ─" ) group_result = await self.send_to_group(text=summary) group_msg_id = ( @@ -3076,21 +3072,28 @@ class TelegramGateway: ) async def _handle_chat_message(self, update_id: int, message: dict) -> None: - """處理统帥的文字訊息""" + """處理统帥的文字訊息(個人 chat 或 SRE 群組)""" text = message.get("text") user = message.get("from", {}) user_id = user.get("id") chat_id = message.get("chat", {}).get("id") + chat_type = message.get("chat", {}).get("type", "private") + message_id = message.get("message_id") username = user.get("username") or user.get("first_name") or str(user_id) if not text or not user_id: return + # Bot 訊息忽略(避免 Bot 互相觸發無限循環) + if user.get("is_bot"): + return + logger.info( "telegram_chat_received", update_id=update_id, user_id=user_id, username=username, + chat_type=chat_type, text=text[:50], ) @@ -3103,17 +3106,11 @@ class TelegramGateway: return # 2. /ai 指令攔截 (Phase 24 C — 2026-04-03 ogt) - # 白名單: OPENCLAW_TG_USER_WHITELIST (與審核白名單共用) if text.strip().lower().startswith("/ai"): - # C1 修復 (2026-04-03 首席架構師審查): Fail-Closed — 白名單空時拒絕所有人 whitelist = settings.get_tg_user_whitelist() if not whitelist or user_id not in whitelist: logger.warning("telegram_ai_command_unauthorized", user_id=user_id, whitelist_empty=not whitelist) - await self.send_notification( - "⛔ 未授權:/ai 指令僅限白名單用戶", - parse_mode="HTML", - chat_id=chat_id, - ) + await self.send_notification("⛔ 未授權:/ai 指令僅限白名單用戶", parse_mode="HTML", chat_id=chat_id) return from src.services.ai_control import handle_ai_command response = await handle_ai_command(text.strip()) @@ -3121,20 +3118,126 @@ class TelegramGateway: logger.info("telegram_ai_command_handled", user_id=user_id, text=text[:50]) return - # 3. 顯示 "正在輸入中..." + # 3. 群組訊息路由 (2026-04-03 ogt: SRE 戰情室群組支援) + # 群組裡 @ 指定 Bot 或直接發訊息 → 雙 AI 並行回應到群組 + is_group = chat_type in ("group", "supergroup") + is_sre_group = str(chat_id) == str(settings.SRE_GROUP_CHAT_ID) + + if is_group and is_sre_group: + await self._handle_group_message(text, user_id, username, chat_id, message_id) + return + + # 4. 個人 chat — 顯示輸入狀態 await self._send_chat_action(chat_id, "typing") - # 4. 呼叫 ChatManager 處理 + # 5. ChatManager 處理(個人 chat) chat_manager = get_chat_manager() response = await chat_manager.generate_response( user_id=user_id, username=username, message_text=text, ) - - # 5. 回覆統帥 (定向回傳) await self.send_notification(response, parse_mode="HTML", chat_id=chat_id) + async def _handle_group_message( + self, + text: str, + user_id: int, + username: str, + chat_id: int, # noqa: ARG002 + message_id: int | None, + ) -> None: + """ + 處理 SRE 群組訊息 (2026-04-03 ogt: Phase 22.6 Triumvirate) + + 路由規則: + @OpenClawAwoooI_Bot → 只有 OpenClaw 回應 + @NemoTronAwoooI_Bot → 只有 NemoClaw 回應 + 其他訊息 → 兩個 AI 並行回應,互相看到後可評論 + """ + from src.services.chat_manager import get_chat_manager as _get_cm + chat_mgr = _get_cm() + + text_lower = text.lower() + mention_openclaw = "@openclawawoooi_bot" in text_lower + mention_nemo = "@nemotronawoooi_bot" in text_lower + + # 去掉 @ mention 取出純訊息 + clean_text = text + for mention in ["@openclawawoooi_bot", "@NemoTronAwoooI_Bot", "@OpenClawAwoooI_Bot", "@NemoTronAwoooI_Bot"]: + clean_text = clean_text.replace(mention, "").strip() + if not clean_text: + clean_text = text + + context = await chat_mgr.get_system_context() + + if mention_openclaw and not mention_nemo: + # 只 OpenClaw 回應 + result = await chat_mgr._call_openclaw( + f"{context}\n用戶 {username} 在 SRE 戰情室問你:", + clean_text, + ) + await self.send_as_openclaw( + text=f"🦞 OpenClaw\n\n{result or '🔴 無響應'}", + reply_to_message_id=message_id, + ) + + elif mention_nemo and not mention_openclaw: + # 只 NemoClaw 回應 + result = await chat_mgr._call_nemotron( + f"{context}\n用戶 {username} 在 SRE 戰情室問你:", + clean_text, + ) + await self.send_as_nemotron( + text=f"🤖 NemoClaw\n\n{result or '🔴 無響應 (NIM 超時)'}", + reply_to_message_id=message_id, + ) + + else: + # 兩個 AI 並行回應,完成後互相評論 + oc_task = asyncio.create_task( + chat_mgr._call_openclaw(f"{context}\n用戶 {username} 在 SRE 戰情室:", clean_text) + ) + nemo_task = asyncio.create_task( + chat_mgr._call_nemotron(f"{context}\n用戶 {username} 在 SRE 戰情室:", clean_text) + ) + oc_result, nemo_result = await asyncio.gather(oc_task, nemo_task, return_exceptions=True) + + if oc_result and not isinstance(oc_result, Exception): + await self.send_as_openclaw( + text=f"🦞 OpenClaw\n\n{oc_result}", + reply_to_message_id=message_id, + ) + + if nemo_result and not isinstance(nemo_result, Exception): + await self.send_as_nemotron( + text=f"🤖 NemoClaw\n\n{nemo_result}", + reply_to_message_id=message_id, + ) + + # 互相評論:OpenClaw 看 Nemo 的回應,Nemo 看 OpenClaw 的回應 + if oc_result and nemo_result and not isinstance(oc_result, Exception) and not isinstance(nemo_result, Exception): + oc_comment_task = asyncio.create_task( + chat_mgr._call_openclaw( + f"{context}\nNemoClaw 說:{nemo_result[:300]}\n你有什麼補充或不同看法?簡短回應。", + clean_text, + ) + ) + nemo_comment_task = asyncio.create_task( + chat_mgr._call_nemotron( + f"{context}\nOpenClaw 說:{oc_result[:300]}\n你有什麼補充或不同看法?簡短回應。", + clean_text, + ) + ) + oc_comment, nemo_comment = await asyncio.gather(oc_comment_task, nemo_comment_task, return_exceptions=True) + + if oc_comment and not isinstance(oc_comment, Exception): + await self.send_as_openclaw(text=f"🦞 OpenClaw 補充\n\n{oc_comment}") + if nemo_comment and not isinstance(nemo_comment, Exception): + await self.send_as_nemotron(text=f"🤖 NemoClaw 補充\n\n{nemo_comment}") + + logger.info("group_message_handled", user_id=user_id, text=text[:50]) + async def _send_chat_action(self, chat_id: int, action: str) -> None: """發送聊天狀態 (e.g., typing)""" if not self._http_client: return