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