Files
awoooi/apps/api/src/api/v1/telegram.py
OG T 665f93e83f
Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
fix(telegram): 首席架構師 R1 修正 — I-1/I-2/M-1/M-2
I-1: webhooks/sentry_webhook/signoz_webhook 三個呼叫者補 TODO 說明
     無 incident_id 是已知限制(Approval 路徑未建 Incident 關聯)
I-2: TestPushRequest 新增 incident_id 欄位,使 QA 可驗證按鈕渲染
M-1: 移除 _build_inline_keyboard 呼叫中多餘的 `or message.incident_id`
M-2: 補充 900/1000 截斷長度差異說明

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-05 13:07:42 +08:00

273 lines
9.5 KiB
Python

"""
Telegram Gateway API - OpenClaw 行動簽核通道
=============================================
Phase 5.4: Telegram Gateway 整合
Phase 5.5: Long Polling 重構 (內網修復)
架構變更 (2026-03-22):
- 舊: Webhook 模式 (需外網可達) - 已廢除
- 新: Long Polling 模式 (主動輪詢) - 適用內網環境
Endpoints:
- POST /api/v1/telegram/webhook - [已棄用] 接收 Telegram Bot Update
- POST /api/v1/telegram/test-push - 測試推送 (僅開發模式)
- GET /api/v1/telegram/health - Gateway 健康檢查
安全鐵律:
- 所有簽核必須通過 SecurityInterceptor 驗證
- 只有白名單內的 user_id 可以簽核
- 每個 Nonce 只能使用一次
"""
from uuid import UUID
from fastapi import APIRouter, HTTPException, status
from pydantic import BaseModel
from src.core.config import settings
from src.core.logging import get_logger
from src.services.approval_db import get_approval_service
from src.services.security_interceptor import (
NonceReplayError,
UserNotWhitelistedError,
)
from src.services.telegram_gateway import TelegramGatewayError, get_telegram_gateway
logger = get_logger("awoooi.telegram")
router = APIRouter(prefix="/telegram", tags=["Telegram"])
# =============================================================================
# Request Models
# =============================================================================
class TelegramUpdate(BaseModel):
"""
Telegram Bot API Update
簡化版本,僅處理 callback_query (簽核按鈕點擊)
"""
update_id: int
callback_query: dict | None = None
message: dict | None = None
class TestPushRequest(BaseModel):
"""測試推送請求 (僅開發模式)"""
approval_id: str
risk_level: str = "medium"
resource_name: str = "test-pod-123"
root_cause: str = "Test alert for development"
suggested_action: str = "DELETE_POD"
estimated_downtime: str = "~30s"
# 2026-04-05 Claude Code: 支援 incident_id 以測試第二排按鈕渲染
incident_id: str = ""
# =============================================================================
# Endpoints
# =============================================================================
@router.post(
"/webhook",
summary="[已棄用] Telegram Bot Webhook",
description="⚠️ 已棄用:內網環境請使用 Long Polling 模式。此端點保留供外網環境或測試使用。",
deprecated=True,
)
async def telegram_webhook(
update: TelegramUpdate,
) -> dict:
"""
接收 Telegram Bot Update
處理流程:
1. 驗證 Update 類型 (僅處理 callback_query)
2. 安全驗證 (白名單 + Nonce)
3. 解析簽核動作 (approve/reject)
4. 更新資料庫
5. 回應 Telegram
"""
logger.info("telegram_webhook_received", update_id=update.update_id)
# =========================================================================
# Step 1: 僅處理 callback_query (簽核按鈕點擊)
# =========================================================================
if not update.callback_query:
logger.debug("telegram_webhook_ignored", reason="not callback_query")
return {"ok": True, "message": "Ignored (not callback_query)"}
callback = update.callback_query
callback_query_id = callback.get("id")
callback_data = callback.get("data")
user = callback.get("from", {})
user_id = user.get("id")
username = user.get("username") or user.get("first_name") or str(user_id)
message = callback.get("message", {})
message_id = message.get("message_id")
original_text = message.get("text", "")
if not all([callback_query_id, callback_data, user_id]):
logger.warning("telegram_webhook_invalid", reason="missing required fields")
return {"ok": False, "message": "Invalid callback data"}
# =========================================================================
# Step 2: 安全驗證 + 處理回調
# =========================================================================
try:
gateway = get_telegram_gateway()
result = await gateway.handle_callback(
callback_query_id=callback_query_id,
callback_data=callback_data,
user_id=user_id,
message_id=message_id,
original_text=original_text,
username=username,
)
if not result.get("success"):
return {"ok": False, "message": result.get("error")}
# =====================================================================
# Step 2.5: ADR-050 Info Actions (read-only, 無需 DB 操作)
# =====================================================================
if result.get("info_action"):
return {"ok": True, "message": f"info:{result['action']}", "approval_id": result["approval_id"]}
# =====================================================================
# Step 3: 更新資料庫 (簽核/拒絕)
# =====================================================================
action = result["action"]
approval_id = result["approval_id"]
_telegram_user = result["user"] # reserved for future audit logging
service = get_approval_service()
# 2026-03-29 ogt: 修復方法呼叫 - add_signature/reject 不存在
# 正確方法: sign_approval / reject_approval
if action == "approve":
approval, msg, execution_triggered = await service.sign_approval(
approval_id=UUID(approval_id),
signer_id=f"tg_{user_id}",
signer_name=user.get("username") or user.get("first_name") or str(user_id),
comment="Telegram 簽核",
)
if approval:
logger.info(
"telegram_approval_signed",
approval_id=approval_id,
user_id=user_id,
status=approval.status.value,
execution_triggered=execution_triggered,
)
return {
"ok": True,
"message": "Approved",
"approval_id": approval_id,
"status": approval.status.value,
"execution_triggered": execution_triggered,
}
elif action == "reject":
approval, msg = await service.reject_approval(
approval_id=UUID(approval_id),
rejector_id=f"tg_{user_id}",
rejector_name=user.get("username") or str(user_id),
reason="Telegram 拒絕",
)
if approval:
logger.info(
"telegram_approval_rejected",
approval_id=approval_id,
user_id=user_id,
)
return {
"ok": True,
"message": "Rejected",
"approval_id": approval_id,
"status": approval.status.value,
}
return {"ok": False, "message": "Unknown action"}
except UserNotWhitelistedError as e:
logger.warning("telegram_webhook_denied", user_id=user_id, error=str(e))
return {"ok": False, "message": "User not authorized"}
except NonceReplayError as e:
logger.warning("telegram_webhook_replay", error=str(e))
return {"ok": False, "message": "Already processed"}
except Exception as e:
logger.error("telegram_webhook_error", error=str(e))
return {"ok": False, "message": str(e)}
@router.post(
"/test-push",
summary="測試推送 (僅開發模式)",
description="測試推送簽核卡片到 Telegram (僅在 dev 環境可用)",
)
async def test_push(
request: TestPushRequest,
) -> dict:
"""
測試推送簽核卡片到 Telegram
僅在開發模式下可用
"""
# 生產環境禁止
if settings.ENVIRONMENT == "prod":
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Test push is disabled in production",
)
try:
gateway = get_telegram_gateway()
result = await gateway.send_approval_card(
approval_id=request.approval_id,
risk_level=request.risk_level,
resource_name=request.resource_name,
root_cause=request.root_cause,
suggested_action=request.suggested_action,
estimated_downtime=request.estimated_downtime,
incident_id=request.incident_id,
)
return {
"ok": True,
"message": "Test push sent",
"telegram_response": result,
}
except TelegramGatewayError as e:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Telegram API error: {str(e)}",
) from e
@router.get(
"/health",
summary="Telegram Gateway 健康檢查",
)
async def telegram_health() -> dict:
"""Telegram Gateway 健康狀態 (含 Long Polling 狀態)"""
gateway = get_telegram_gateway()
return {
"status": "configured" if settings.OPENCLAW_TG_BOT_TOKEN else "not_configured",
"mode": "long_polling", # Phase 5.5: 已從 webhook 切換至 long_polling
"polling_active": gateway._polling_active,
"bot_token_set": bool(settings.OPENCLAW_TG_BOT_TOKEN),
"chat_id_set": bool(settings.OPENCLAW_TG_CHAT_ID),
"whitelist_count": len(settings.OPENCLAW_TG_USER_WHITELIST),
"last_update_id": gateway._last_update_id,
"environment": settings.ENVIRONMENT,
}