feat(approval): 批准/拒絕後立即回應 Telegram + 持久化 message_id 到 DB
All checks were successful
CD Pipeline / build-and-deploy (push) Successful in 13m9s

問題:按下 TG 批准/拒絕按鈕後完全沒有任何回應,使用者不知道是否成功。
      Telegram message_id 只存 Redis 24h TTL,過期後無法追蹤。

修正:
- approval_records 加 telegram_message_id / telegram_chat_id 欄位(已 ALTER TABLE)
- approval_db.update_telegram_message() — 持久化 message_id 到 DB
- decision_manager: 發送告警卡片後同時寫 Redis + DB
- telegram_gateway._notify_approval_result() — 批准/拒絕後:
    1. editMessageReplyMarkup 移除批准/拒絕按鈕,保留資訊按鈕
    2. sendMessage reply_to 在原訊息下回覆狀態行
    3. fallback: send_notification 發新訊息
- _handle_group_command: chat_id 改為 _chat_id 消除 IDE lint

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-09 18:19:31 +08:00
parent 2c7d5d049c
commit 1483218bab
4 changed files with 153 additions and 4 deletions

View File

@@ -154,6 +154,19 @@ class ApprovalRecord(Base):
comment="Associated Incident ID (INC-YYYYMMDD-XXXXXX)",
)
# 2026-04-09 Claude Sonnet 4.6: Telegram 訊息持久化
# Redis tg_msg:{id} TTL 24h 過期後仍可查詢,支援跨 Session 狀態更新
telegram_message_id: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
comment="Telegram message_id of the approval card sent to operator",
)
telegram_chat_id: Mapped[int | None] = mapped_column(
Integer,
nullable=True,
comment="Telegram chat_id where the approval card was sent",
)
# Timestamps
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),

View File

@@ -610,6 +610,27 @@ class ApprovalDBService:
.values(incident_id=incident_id)
)
async def update_telegram_message(
self, incident_id: str, telegram_message_id: int, telegram_chat_id: int | None = None
) -> None:
"""
2026-04-09 Claude Sonnet 4.6: 持久化 Telegram message_id 到 DB
讓告警訊息 ID 不再只存 Redis24h TTL支援長期狀態追蹤和訊息更新。
以 incident_id 查找最新 PENDING approval record 並回填。
"""
async with get_db_context() as db:
values: dict = {"telegram_message_id": telegram_message_id}
if telegram_chat_id is not None:
values["telegram_chat_id"] = telegram_chat_id
await db.execute(
update(ApprovalRecord)
.where(
ApprovalRecord.incident_id == incident_id,
ApprovalRecord.status == ApprovalStatus.PENDING,
)
.values(**values)
)
# =========================================================================
# Phase 6.4h: Proposals API 支援方法
# =========================================================================

View File

@@ -183,9 +183,22 @@ async def _push_decision_to_telegram(
)
# 2026-04-09 Claude Sonnet 4.6: 存 message_id → 後續狀態更新在原訊息延續
# 同時寫 Redis (快速查詢) 和 DB (持久化,不受 TTL 限制)
tg_message_id = tg_result.get("result", {}).get("message_id") if isinstance(tg_result, dict) else None
tg_chat_id = tg_result.get("result", {}).get("chat", {}).get("id") if isinstance(tg_result, dict) else None
if tg_message_id:
await redis.setex(f"tg_msg:{incident.incident_id}", 86400, str(tg_message_id))
# 持久化到 DB
try:
from src.services.approval_db import get_approval_service as _get_approval_svc
_approval_svc = _get_approval_svc()
await _approval_svc.update_telegram_message(
incident_id=incident.incident_id,
telegram_message_id=tg_message_id,
telegram_chat_id=tg_chat_id,
)
except Exception as _e:
logger.warning("telegram_message_id_db_save_failed", incident_id=incident.incident_id, error=str(_e))
# 🔴 發送成功後設置去重 key (TTL 10 分鐘)
await redis.setex(dedup_key, 600, "1")

View File

@@ -3622,7 +3622,7 @@ class TelegramGateway:
logger.info("group_message_handled", user_id=user_id, text=text[:50])
async def _handle_group_command(self, cmd: str, chat_id: int, message_id: int | None) -> None: # noqa: ARG002
async def _handle_group_command(self, cmd: str, _chat_id: int, message_id: int | None) -> None:
"""
SRE 群組 Slash Commands (2026-04-03 ogt: 方案B)
@@ -3730,6 +3730,95 @@ class TelegramGateway:
await self._http_client.post(url, json={"chat_id": chat_id, "action": action})
except: pass
async def _notify_approval_result(
self,
message_id: int | None,
incident_id: str,
action: str,
username: str,
execution_triggered: bool,
) -> None:
"""
2026-04-09 Claude Sonnet 4.6: 批准/拒絕後立即更新 Telegram 訊息狀態。
策略:
1. editMessageReplyMarkup — 移除批准/拒絕按鈕,保留資訊按鈕
2. sendMessage reply_to → 在原訊息下方附加狀態行
3. 如果 message_id 找不到fallback 到 send_notification
"""
import html as _html
chat_id = settings.OPENCLAW_TG_CHAT_ID
if not chat_id:
return
# 找到原始告警訊息 ID優先 Redisfallback DB
orig_msg_id = message_id
if not orig_msg_id:
try:
redis = await get_redis()
_val = await redis.get(f"tg_msg:{incident_id}")
if _val:
orig_msg_id = int(_val)
else:
# DB fallback
from src.services.approval_db import get_approval_service as _svc
_approvals = await _svc().get_all_approvals(incident_id=incident_id)
if _approvals and _approvals[0].telegram_message_id:
orig_msg_id = _approvals[0].telegram_message_id
except Exception:
pass
if action == "approve":
status_emoji = ""
status_text = f"<b>已批准</b> by {_html.escape(username)}"
suffix = "⚡ 執行中..." if execution_triggered else "👀 等待執行"
else:
status_emoji = ""
status_text = f"<b>已拒絕</b> by {_html.escape(username)}"
suffix = ""
status_line = f"{status_emoji} {status_text} {suffix}".strip()
if orig_msg_id:
try:
# 1. 移除批准/拒絕按鈕(只保留資訊按鈕列)
info_buttons = [[
{"text": "📋 詳情", "callback_data": f"detail:{incident_id}"},
{"text": "📊 歷史", "callback_data": f"history:{incident_id}"},
]]
await self._http_client.post(
f"https://api.telegram.org/bot{settings.OPENCLAW_TG_BOT_TOKEN}/editMessageReplyMarkup",
json={
"chat_id": chat_id,
"message_id": orig_msg_id,
"reply_markup": {"inline_keyboard": info_buttons},
},
)
except Exception:
pass
try:
# 2. 在原訊息下回覆狀態
await self._http_client.post(
f"https://api.telegram.org/bot{settings.OPENCLAW_TG_BOT_TOKEN}/sendMessage",
json={
"chat_id": chat_id,
"text": status_line,
"parse_mode": "HTML",
"reply_to_message_id": orig_msg_id,
},
)
return
except Exception:
pass
# fallback: 發新通知
try:
await self.send_notification(status_line, parse_mode="HTML")
except Exception:
pass
async def _execute_approval_action(
self,
action: str,
@@ -3775,7 +3864,6 @@ class TelegramGateway:
return
if action == "approve":
# 2026-03-29 ogt: 正確呼叫 sign_approval (返回 tuple)
approval, message, execution_triggered = await service.sign_approval(
approval_id=approval_uuid,
signer_id=f"tg_{user_id}",
@@ -3791,9 +3879,16 @@ class TelegramGateway:
status=approval.status.value,
execution_triggered=execution_triggered,
)
# 2026-04-09 Claude Sonnet 4.6: 回應 Telegram — 更新訊息狀態 + answer callback
await self._notify_approval_result(
message_id=message_id,
incident_id=approval_id,
action="approve",
username=username,
execution_triggered=execution_triggered,
)
elif action == "reject":
# 2026-03-29 ogt: 正確呼叫 reject_approval (返回 tuple)
approval, message = await service.reject_approval(
approval_id=approval_uuid,
rejector_id=f"tg_{user_id}",
@@ -3807,9 +3902,16 @@ class TelegramGateway:
approval_id=approval_id,
user_id=user_id,
)
# 2026-04-09 Claude Sonnet 4.6: 回應 Telegram — 更新訊息狀態
await self._notify_approval_result(
message_id=message_id,
incident_id=approval_id,
action="reject",
username=username,
execution_triggered=False,
)
elif action == "tune":
# 自動調優已在 handle_callback 中處理
logger.info(
"telegram_auto_tuning_via_polling",
approval_id=approval_id,