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