ForcePilot/backend/package/yuxi/channel/extensions/kakaotalk/webhook.py
Kris 8b66218d9b feat(channel): 添加 KakaoTalk 渠道扩展
新增 KakaoTalk 渠道扩展,支持在 Yuxi 平台中集成 KakaoTalk 即时通讯渠道。

包含以下功能模块:
- bot: Bot 客户端封装
- config: 渠道配置管理
- gateway: SSE/WebSocket 网关接入
- webhook: Webhook 事件处理
- outbound: 外发消息管理
- streaming: 流式消息处理
- pairing: 用户配对与绑定
- security: 安全校验
- dedupe: 消息去重
- monitor: 渠道状态监控
- status: 会话状态管理
- card_builder: KakaoTalk 卡片消息构建
- quick_reply: 快捷回复处理
- types: 类型定义
2026-05-21 11:12:31 +08:00

159 lines
5.0 KiB
Python

from __future__ import annotations
import asyncio
import logging
import os
from fastapi import APIRouter, Header, Request
from fastapi.responses import JSONResponse, PlainTextResponse
from yuxi.channel.extensions.kakaotalk.config import KakaoTalkConfigAdapter, ENV_KAKAO_ADMIN_KEY
from yuxi.channel.extensions.kakaotalk.dedupe import KakaoTalkDeduplicator, build_dedupe_key
from yuxi.channel.extensions.kakaotalk.monitor import KakaoTalkMonitor
logger = logging.getLogger(__name__)
MAX_BODY_BYTES = 64 * 1024
BODY_TIMEOUT_S = 5.0
router = APIRouter(prefix="/webhook/kakaotalk", tags=["kakaotalk"])
class KakaoTalkWebhookHandler:
def __init__(self):
self._running = False
self._config = KakaoTalkConfigAdapter()
self._monitor = KakaoTalkMonitor()
self._dedupe = KakaoTalkDeduplicator()
def start(self) -> None:
self._running = True
logger.info("KakaoTalk webhook handler started")
def stop(self) -> None:
self._running = False
self._dedupe.reset()
logger.info("KakaoTalk webhook handler stopped")
def configure(self, config: dict) -> None:
self._config.list_account_ids(config)
@property
def running(self) -> bool:
return self._running
@property
def config(self) -> KakaoTalkConfigAdapter:
return self._config
_handler = KakaoTalkWebhookHandler()
@router.post("/skill")
async def kakaotalk_skill_callback(
request: Request,
x_kakao_admin_key: str = Header("", alias="X-Kakao-Admin-Key"),
):
if not _handler.running:
return PlainTextResponse("", status_code=503)
body = await _read_body_with_limit(request)
if body is None:
return PlainTextResponse("", status_code=413)
account, account_id, admin_key = await _resolve_account_for_webhook(body, x_kakao_admin_key)
if not account:
return PlainTextResponse("", status_code=401)
payload = _handler._monitor.parse_webhook_body(body)
skill_request = _handler._monitor.parse_skill_request(payload)
if not skill_request:
return JSONResponse(content={
"version": "2.0",
"template": {"outputs": [{"simpleText": {"text": "요청을 처리할 수 없습니다."}}]},
})
dedupe_key = build_dedupe_key(account_id, skill_request)
if _handler._dedupe.is_duplicate(dedupe_key):
logger.debug("KakaoTalk duplicate skill request: key=%s", dedupe_key)
return JSONResponse(content={
"version": "2.0",
"template": {"outputs": [{"simpleText": {"text": ""}}]},
})
um = _handler._monitor.build_unified_message(skill_request, account_id)
if um:
asyncio.create_task(
_dispatch_to_agent(um),
name=f"kakaotalk-dispatch-{um.sender.id}",
)
return JSONResponse(content={
"version": "2.0",
"template": {"outputs": [{"simpleText": {"text": ""}}]},
})
async def _resolve_account_for_webhook(body: bytes, header_admin_key: str) -> tuple[dict | None, str, str]:
account_ids = _handler._config.list_account_ids({})
accounts = []
for aid in account_ids:
try:
acct = await _handler._config.resolve_account(aid)
if acct:
accounts.append(acct)
except Exception:
logger.exception("KakaoTalk resolve_account error for %s", aid)
if len(accounts) == 1:
acct = accounts[0]
if header_admin_key and header_admin_key == acct.get("admin_key", ""):
return acct, acct.get("account_id", "default"), acct.get("admin_key", "")
return acct, acct.get("account_id", "default"), acct.get("admin_key", "")
for acct in accounts:
if header_admin_key and header_admin_key == acct.get("admin_key", ""):
return acct, acct.get("account_id", "default"), acct.get("admin_key", "")
env_key = os.environ.get(ENV_KAKAO_ADMIN_KEY, "")
if env_key:
return {
"account_id": "default",
"admin_key": env_key,
"name": "default",
}, "default", env_key
return None, "", ""
async def _dispatch_to_agent(msg) -> None:
try:
from yuxi.channel.runtime.manager import gateway
processor = getattr(gateway, "_processor", None)
if processor is None:
logger.warning("KakaoTalk dispatch: message processor not available")
return
await asyncio.wait_for(processor.process(msg), timeout=120.0)
except TimeoutError:
logger.error("KakaoTalk agent response timeout for user %s", msg.sender.id)
except Exception:
logger.exception("KakaoTalk dispatch error for user %s", msg.sender.id)
async def _read_body_with_limit(request: Request) -> bytes | None:
try:
body = await asyncio.wait_for(request.body(), timeout=BODY_TIMEOUT_S)
except TimeoutError:
logger.warning("KakaoTalk webhook: body read timeout")
return None
if len(body) > MAX_BODY_BYTES:
logger.warning("KakaoTalk webhook: body too large (%d bytes)", len(body))
return None
return body