Some checks failed
CD Pipeline / build-and-deploy (push) Has been cancelled
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>
273 lines
9.5 KiB
Python
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,
|
|
}
|