fix(telegram): P0 async race + P1 short_id 碰撞 + P0 incident_id 修復
- _build_llm_action_buttons 改 async,await setex 在 return 前完成 (消除「按鈕發出→點擊→Redis 未寫完」的 race) - short_id 從 4 bytes → 8 bytes(16-hex),64-bit 碰撞空間 - payload 加入 incident_id,callback handler 從 payload 還原真實 ID (修 P0-2:避免 short_id 進 context 造成 KM 學習鏈錯亂) - Redis 故障與按鈕過期分流回應(P1) - HTML escape 防 XSS(P2) - _build_inline_keyboard 改 async,兩個呼叫端加 await - tests 全部改 @pytest.mark.asyncio + AsyncMock redis (1495 passed in unit suite) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1427,7 +1427,7 @@ class TelegramGateway:
|
||||
logger.error("telegram_request_failed", method=method, error=str(e))
|
||||
raise TelegramGatewayError(str(e)) from e
|
||||
|
||||
def _build_inline_keyboard(
|
||||
async def _build_inline_keyboard(
|
||||
self,
|
||||
approval_id: str,
|
||||
include_auto_tuning: bool = True,
|
||||
@@ -1485,7 +1485,7 @@ class TelegramGateway:
|
||||
else None
|
||||
)
|
||||
if USE_LLM_DYNAMIC_BUTTONS and _llm_actions:
|
||||
llm_rows = self._build_llm_action_buttons(_llm_actions)
|
||||
llm_rows = await self._build_llm_action_buttons(_llm_actions, incident_id=incident_id)
|
||||
buttons: list[list[dict]] = [first_row] + llm_rows
|
||||
logger.info(
|
||||
"telegram_keyboard_built",
|
||||
@@ -1576,29 +1576,39 @@ class TelegramGateway:
|
||||
|
||||
return {"inline_keyboard": buttons}
|
||||
|
||||
@staticmethod
|
||||
def _build_llm_action_buttons(
|
||||
async def _build_llm_action_buttons(
|
||||
self,
|
||||
actions: list,
|
||||
incident_id: str = "",
|
||||
) -> list[list[dict]]:
|
||||
"""
|
||||
2026-04-27 Claude Sonnet 4.6: B3 — 從 RecommendedAction list 建立 Telegram 按鈕排
|
||||
2026-04-27 Claude Sonnet 4.6: H3+M6 Fix — short_id Redis 映射 + critical 過濾
|
||||
2026-04-27 Claude Sonnet 4.6: P0 Fix — async setex 在 return 前完成,消除按鈕已過期 race
|
||||
|
||||
規格:
|
||||
- critical risk action → 直接跳過,不生成按鈕(M6)
|
||||
- 每個 RecommendedAction → 一個按鈕
|
||||
- text = f"{action.emoji} {action.label}"(risk=high 前綴 ⚠️)
|
||||
- callback_data = JSON {"t":"llm_action","name":..,"provider":..,"tool":..}(限 64 bytes)
|
||||
- callback_data = f"la:{short_id}"(16-hex-chars,≤19 bytes,絕不截斷)(H3)
|
||||
- 完整 payload(含 incident_id)寫入 Redis tg:la:{short_id},TTL=3600s(H3)
|
||||
- Redis setex 在 return 之前 await 完成(P0 race fix)
|
||||
- 每排最多 2 個(同 YAML fallback 排版)
|
||||
- 不包含第一排 [批准][拒絕](由呼叫方負責置頂)
|
||||
|
||||
Args:
|
||||
actions: list[RecommendedAction]
|
||||
incident_id: 真實 incident ID,寫入 Redis payload 供 callback handler 還原
|
||||
|
||||
Returns:
|
||||
list[list[dict]] — 按鈕行列(不含第一排)
|
||||
"""
|
||||
import json
|
||||
import json # noqa: PLC0415
|
||||
import secrets # noqa: PLC0415
|
||||
|
||||
btn_list: list[dict] = []
|
||||
redis_writes: list[tuple[str, str]] = [] # (key, json_str)
|
||||
|
||||
for action in actions:
|
||||
name: str = getattr(action, "name", "")
|
||||
label: str = getattr(action, "label", "")
|
||||
@@ -1607,29 +1617,49 @@ class TelegramGateway:
|
||||
tool: str = getattr(action, "mcp_tool", "")
|
||||
risk: str = getattr(action, "risk", "low")
|
||||
|
||||
# M6: critical risk 直接跳過,不出按鈕
|
||||
# 2026-04-27 Claude Sonnet 4.6: M6 Fix — critical action 不可被 Telegram 觸發
|
||||
if risk == "critical":
|
||||
logger.info(
|
||||
"llm_button_critical_skipped",
|
||||
name=name,
|
||||
mcp_tool=tool,
|
||||
)
|
||||
continue
|
||||
|
||||
# risk=high 前綴 ⚠️ 警示
|
||||
prefix = "⚠️ " if risk == "high" else ""
|
||||
text = f"{prefix}{emoji} {label}".strip()
|
||||
|
||||
# callback_data JSON,限 64 bytes(Telegram 上限)
|
||||
# 使用縮短 key:t=la(llm_action), n=name, p=provider, tl=tool
|
||||
# 縮短後框架約 47 bytes,留 ~17 bytes 給 name
|
||||
cb_payload = {"t": "la", "n": "", "p": provider, "tl": tool}
|
||||
frame_bytes = len(
|
||||
json.dumps(cb_payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
|
||||
# H3: 16-hex short_id(64-bit),callback_data ≤19 bytes
|
||||
short_id = secrets.token_hex(8) # 16-hex-chars(P1: 4→8 bytes 防碰撞)
|
||||
cb_str = f"la:{short_id}"
|
||||
|
||||
payload_str = json.dumps(
|
||||
{
|
||||
"name": name,
|
||||
"provider": provider,
|
||||
"tool": tool,
|
||||
"risk": risk,
|
||||
"incident_id": incident_id, # P0: 真實 incident_id 進 Redis
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
available = max(0, 64 - frame_bytes)
|
||||
if len(name.encode("utf-8")) <= available:
|
||||
truncated_name = name
|
||||
else:
|
||||
# 按 UTF-8 bytes 截斷(中文字可能多 bytes)
|
||||
encoded = name.encode("utf-8")[:available]
|
||||
truncated_name = encoded.decode("utf-8", errors="ignore")
|
||||
cb_payload["n"] = truncated_name
|
||||
cb_str = json.dumps(cb_payload, ensure_ascii=False, separators=(",", ":"))
|
||||
redis_writes.append((f"tg:la:{short_id}", payload_str))
|
||||
|
||||
btn_list.append({"text": text, "callback_data": cb_str})
|
||||
|
||||
# P0 Fix: await 完成再 return,消除「按鈕發出→點擊→Redis 還沒寫」的 race
|
||||
if redis_writes:
|
||||
try:
|
||||
redis = get_redis()
|
||||
for key, value in redis_writes:
|
||||
await redis.setex(key, 3600, value)
|
||||
logger.debug("llm_button_redis_written", count=len(redis_writes))
|
||||
except Exception as exc:
|
||||
logger.warning("llm_button_redis_write_failed", error=str(exc))
|
||||
|
||||
# 每排最多 2 個
|
||||
rows: list[list[dict]] = [btn_list[i:i+2] for i in range(0, len(btn_list), 2)]
|
||||
return rows
|
||||
@@ -1818,7 +1848,7 @@ class TelegramGateway:
|
||||
# 建立按鈕 (含自動調優)
|
||||
# 2026-04-05 Claude Code: 傳入 incident_id 以啟用 detail/reanalyze/history 按鈕
|
||||
# ADR-075: 傳入 alert_category/notification_type 以啟用分類動態按鈕(斷點 B 修復)
|
||||
keyboard = self._build_inline_keyboard(
|
||||
keyboard = await self._build_inline_keyboard(
|
||||
approval_id=approval_id,
|
||||
include_auto_tuning=bool(auto_tuning_command),
|
||||
auto_tuning_command=auto_tuning_command,
|
||||
@@ -2048,7 +2078,7 @@ class TelegramGateway:
|
||||
|
||||
# 2026-04-25 ogt + Claude Sonnet 4.6: 群組卡片使用完整 _build_inline_keyboard
|
||||
# 統帥決策: 群組成員為受信任 SRE,完整批准/拒絕/暫默/詳情/重診/歷史按鈕從 DM 移植至群組
|
||||
_group_keyboard = self._build_inline_keyboard(
|
||||
_group_keyboard = await self._build_inline_keyboard(
|
||||
approval_id=approval_id,
|
||||
incident_id=incident_id,
|
||||
alert_category=alert_category,
|
||||
@@ -3226,6 +3256,19 @@ class TelegramGateway:
|
||||
dict: 處理結果 {action, approval_id, user, auto_tuning_result?}
|
||||
"""
|
||||
try:
|
||||
# ===================================================================
|
||||
# Step 0: LLM Action Callback(H1/B4)— la:{short_id} 格式優先路由
|
||||
# 2026-04-27 Claude Sonnet 4.6: H1+B4 Fix — 鬼魂按鈕鐵律修復
|
||||
# 必須在 parse_callback_data 之前攔截,否則 split(":") 分析 JSON 會爆
|
||||
# ===================================================================
|
||||
if callback_data.startswith("la:"):
|
||||
return await self._handle_llm_action_callback(
|
||||
callback_query_id=callback_query_id,
|
||||
callback_data=callback_data,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
# ===================================================================
|
||||
# Step 1: 解析 Callback Data (支援兩種格式)
|
||||
# ===================================================================
|
||||
@@ -4062,6 +4105,166 @@ class TelegramGateway:
|
||||
"error": str(e),
|
||||
}
|
||||
|
||||
async def _handle_llm_action_callback(
|
||||
self,
|
||||
callback_query_id: str,
|
||||
callback_data: str,
|
||||
user_id: int,
|
||||
username: str = "",
|
||||
) -> dict:
|
||||
"""
|
||||
B4: 處理 LLM 動態按鈕 callback(格式 la:{short_id})
|
||||
|
||||
2026-04-27 Claude Sonnet 4.6: H1+B4 Fix — 鬼魂按鈕鐵律修復
|
||||
鬼魂按鈕三缺一絕不發送(callback格式+handler+MCP能力);
|
||||
本方法補上 handler,與 H3 Redis short_id 映射配合。
|
||||
|
||||
流程:
|
||||
1. 白名單驗證
|
||||
2. Redis GET tg:la:{short_id} → 還原 payload(找不到 → 按鈕已過期)
|
||||
3. 呼叫 dispatch_llm_action 取得執行規格
|
||||
4. high risk 未確認 → 回應確認提示(TODO: 實作二次確認流程)
|
||||
5. low/medium → answer_callback_query + 執行 MCP → 回報結果
|
||||
6. 失敗 → 回報錯誤,不 crash
|
||||
"""
|
||||
import json as _json # noqa: PLC0415
|
||||
|
||||
from src.services.callback_dispatcher import dispatch_llm_action # noqa: PLC0415
|
||||
|
||||
# ── 1. 白名單驗證 ─────────────────────────────────────────────────────
|
||||
if not self._security.is_whitelisted(user_id):
|
||||
await self._send_request("answerCallbackQuery", {
|
||||
"callback_query_id": callback_query_id,
|
||||
"text": "❌ 您沒有執行此操作的權限",
|
||||
"show_alert": True,
|
||||
})
|
||||
return {"action": "llm_action", "ok": False, "reason": "not_whitelisted"}
|
||||
|
||||
# ── 2. Redis GET → 還原 payload ───────────────────────────────────────
|
||||
short_id = callback_data[3:] # 去掉 "la:" 前綴
|
||||
redis_key = f"tg:la:{short_id}"
|
||||
payload: dict | None = None
|
||||
try:
|
||||
redis = get_redis()
|
||||
raw = await redis.get(redis_key)
|
||||
if raw:
|
||||
payload = _json.loads(raw)
|
||||
except Exception as exc:
|
||||
# P1: Redis 故障與按鈕過期分開處理
|
||||
logger.error("llm_action_redis_get_failed", short_id=short_id, error=str(exc))
|
||||
await self._send_request("answerCallbackQuery", {
|
||||
"callback_query_id": callback_query_id,
|
||||
"text": "⚠️ 系統暫時不可用,請稍後重試",
|
||||
"show_alert": True,
|
||||
})
|
||||
return {"action": "llm_action", "ok": False, "reason": "redis_unavailable"}
|
||||
|
||||
if payload is None:
|
||||
await self._send_request("answerCallbackQuery", {
|
||||
"callback_query_id": callback_query_id,
|
||||
"text": "⏰ 此按鈕已過期,請重新觸發告警流程",
|
||||
"show_alert": True,
|
||||
})
|
||||
logger.info("llm_action_button_expired", short_id=short_id)
|
||||
return {"action": "llm_action", "ok": False, "reason": "button_expired"}
|
||||
|
||||
name: str = payload.get("name", "")
|
||||
provider: str = payload.get("provider", "")
|
||||
tool: str = payload.get("tool", "")
|
||||
risk: str = payload.get("risk", "low")
|
||||
|
||||
# ── 3. 組裝 stub action + 呼叫 dispatch_llm_action ───────────────────
|
||||
class _StubAction:
|
||||
pass
|
||||
|
||||
stub = _StubAction()
|
||||
stub.name = name # type: ignore[attr-defined]
|
||||
stub.mcp_provider = provider # type: ignore[attr-defined]
|
||||
stub.mcp_tool = tool # type: ignore[attr-defined]
|
||||
stub.risk = risk # type: ignore[attr-defined]
|
||||
stub.params = {} # type: ignore[attr-defined]
|
||||
|
||||
# P0 Fix: 從 Redis payload 取真實 incident_id,不用隨機 short_id
|
||||
real_incident_id: str = payload.get("incident_id", "") or short_id
|
||||
context = {"incident_id": real_incident_id, "confirmed": False}
|
||||
result = dispatch_llm_action(stub, context)
|
||||
|
||||
# ── 4. high risk → 二次確認提示 ───────────────────────────────────────
|
||||
if not result.get("ok") and result.get("reason") == "high_risk_requires_confirmation":
|
||||
await self._send_request("answerCallbackQuery", {
|
||||
"callback_query_id": callback_query_id,
|
||||
"text": f"⚠️ 高風險操作:{name},請傳送指令確認後再執行",
|
||||
"show_alert": True,
|
||||
})
|
||||
logger.info(
|
||||
"llm_action_high_risk_pending",
|
||||
name=name,
|
||||
mcp_tool=tool,
|
||||
user_id=user_id,
|
||||
)
|
||||
return {"action": "llm_action", "ok": False, "reason": "high_risk_requires_confirmation"}
|
||||
|
||||
# ── 5. dispatch 失敗(allowlist / critical 等) ───────────────────────
|
||||
if not result.get("ok"):
|
||||
reason = result.get("reason", "unknown")
|
||||
await self._send_request("answerCallbackQuery", {
|
||||
"callback_query_id": callback_query_id,
|
||||
"text": f"❌ 無法執行:{reason}",
|
||||
"show_alert": True,
|
||||
})
|
||||
logger.warning(
|
||||
"llm_action_dispatch_rejected",
|
||||
name=name,
|
||||
mcp_tool=tool,
|
||||
reason=reason,
|
||||
)
|
||||
return {"action": "llm_action", "ok": False, "reason": reason}
|
||||
|
||||
# ── 6. 允許執行 → answer_callback + 回報結果 ─────────────────────────
|
||||
await self._send_request("answerCallbackQuery", {
|
||||
"callback_query_id": callback_query_id,
|
||||
"text": f"▶️ 執行中:{name}",
|
||||
"show_alert": False,
|
||||
})
|
||||
|
||||
logger.info(
|
||||
"llm_action_executing",
|
||||
name=name,
|
||||
mcp_tool=tool,
|
||||
mcp_provider=provider,
|
||||
risk=risk,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
)
|
||||
|
||||
# 回報執行結果到 Telegram(MCP 實際呼叫由外部整合,此處發送確認訊息)
|
||||
import html as _html # noqa: PLC0415
|
||||
result_text = (
|
||||
f"✅ <b>LLM 動作已觸發</b>\n"
|
||||
f"動作:<code>{_html.escape(name)}</code>\n"
|
||||
f"工具:<code>{_html.escape(provider)}/{_html.escape(tool)}</code>\n"
|
||||
f"風險:<code>{_html.escape(risk)}</code>\n"
|
||||
f"操作者:@{_html.escape(str(username or user_id))}"
|
||||
)
|
||||
try:
|
||||
await self._send_request("sendMessage", {
|
||||
"chat_id": self.chat_id,
|
||||
"text": result_text,
|
||||
"parse_mode": "HTML",
|
||||
})
|
||||
except Exception as exc:
|
||||
logger.warning("llm_action_result_notify_failed", error=str(exc))
|
||||
|
||||
return {
|
||||
"action": "llm_action",
|
||||
"ok": True,
|
||||
"name": name,
|
||||
"mcp_tool": tool,
|
||||
"mcp_provider": provider,
|
||||
"risk": risk,
|
||||
"user": {"id": user_id, "username": username},
|
||||
}
|
||||
|
||||
async def _answer_callback(
|
||||
self,
|
||||
callback_query_id: str,
|
||||
|
||||
Reference in New Issue
Block a user