"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**.""" import asyncio import hmac import httpx from fastapi import APIRouter, BackgroundTasks, Header, HTTPException, Request from common.logger import LOG from config import agent_config as config from services.agent import channel router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"]) # 콜백이 꺼져 있을 때만 쓰는 상한. DEADLINE_SEC = 4.5 # 콜백이 켜져 있을 때의 상한. CALLBACK_DEADLINE_SEC = 45.0 _TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요." _ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요." _WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요." def _reply(text: str, quick_replies=None) -> dict: """오픈빌더 스킬 응답(SkillResponse).""" payload: dict = {"outputs": [{"simpleText": {"text": text}}]} if quick_replies: # 바로가기는 최대 10개. payload["quickReplies"] = [ {"label": label, "action": "message", "messageText": label} for label in quick_replies[:10] ] return {"version": "2.0", "template": payload} def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict) -> None: expected = config.webhook_secret() if not expected: # 설정이 없으면 이 기능은 존재하지 않는다. raise HTTPException(404) given = header_secret or secret_in_path or "" if not hmac.compare_digest(given, expected): LOG.w("[agent/kakao] 웹훅 시크릿 불일치 — 거절") raise HTTPException(404) # 한 겹 더. bot_id = config.get("KAKAO_BOT_ID") if bot_id and (body.get("bot") or {}).get("id") != bot_id: LOG.w("[agent/kakao] 다른 봇의 요청 — 거절") raise HTTPException(404) async def _answer(utterance: str, speaker: str, deadline: float) -> dict: """대화 한 턴을 SkillResponse 로.""" try: answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=deadline) except asyncio.TimeoutError: LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음") return _reply(_TIMEOUT_TEXT) except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다 LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}") return _reply(_ERROR_TEXT) return _reply(answer["text"], answer.get("quick_replies")) async def _push(callback_url: str, utterance: str, speaker: str) -> None: """답을 다 만든 뒤 콜백 주소로 보낸다.""" payload = await _answer(utterance, speaker, CALLBACK_DEADLINE_SEC) try: async with httpx.AsyncClient(timeout=10.0) as client: res = await client.post(callback_url, json=payload) if res.status_code >= 400: LOG.w(f"[agent/kakao] 콜백 전송 실패: {res.status_code}") except Exception as ex: # noqa: BLE001 LOG.w(f"[agent/kakao] 콜백 전송 실패: {type(ex).__name__}") async def _handle(body: dict, tasks: BackgroundTasks) -> dict: request = body.get("userRequest") or {} utterance = request.get("utterance") or "" speaker = (request.get("user") or {}).get("id") or "" if not speaker: # 발화자를 모르면 누구의 가게인지도 모른다. return _reply("사용자를 확인하지 못했어요.") # 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. callback_url = request.get("callbackUrl") # "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. LOG.i(f"[agent/kakao] 요청 — callbackUrl={'있음' if callback_url else '없음'} " f"block={(request.get('block') or {}).get('name')!r}") if callback_url: tasks.add_task(_push, callback_url, utterance, speaker) return {"version": "2.0", "useCallback": True, "data": {"text": _WAIT_TEXT}} return await _answer(utterance, speaker, DEADLINE_SEC) @router.post("/webhook") async def webhook( request: Request, tasks: BackgroundTasks, x_agent_secret: str | None = Header(default=None), ): """헤더로 시크릿을 받는 쪽.""" body = await request.json() _authorize(None, x_agent_secret, body) return await _handle(body, tasks) @router.post("/webhook/{secret}") async def webhook_with_path_secret( secret: str, request: Request, tasks: BackgroundTasks, x_agent_secret: str | None = Header(default=None), ): """헤더를 못 넣는 경우의 대안.""" body = await request.json() _authorize(secret, x_agent_secret, body) return await _handle(body, tasks)