新增 KakaoTalk 渠道扩展,支持在 Yuxi 平台中集成 KakaoTalk 即时通讯渠道。 包含以下功能模块: - bot: Bot 客户端封装 - config: 渠道配置管理 - gateway: SSE/WebSocket 网关接入 - webhook: Webhook 事件处理 - outbound: 外发消息管理 - streaming: 流式消息处理 - pairing: 用户配对与绑定 - security: 安全校验 - dedupe: 消息去重 - monitor: 渠道状态监控 - status: 会话状态管理 - card_builder: KakaoTalk 卡片消息构建 - quick_reply: 快捷回复处理 - types: 类型定义
159 lines
5.0 KiB
Python
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
|