"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**. `version: "2.0"` · `simpleText` · `quickReplies` 같은 모양이 서비스 계층에 새면, 다른 채널을 붙일 때 그걸 전부 걷어내야 한다. 알림톡 어댑터에 건 것과 같은 규칙이다. ★★ **오픈빌더는 서명을 주지 않는다.** URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.user.id` 를 아무 값이나 넣으면 **그 사장님 행세를 한다** — 신원 연결 (`owner_kakao_links`)이 통째로 무의미해진다. 그래서 공유 시크릿을 우리가 직접 댄다. 시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** — 반쯤 열린 상태를 만들지 않는 것은 Threads 연결과 같은 규칙이다. ★ 5초 벽: 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 사장님에게는 **말없이 실패하는 봇**이 된다. → 오픈빌더 스킬 설정에서 **콜백 사용**을 켜면 요청에 `userRequest.callbackUrl` 이 실려 온다. 그때는 `{"useCallback": true}` 로 **즉답**하고, 답을 다 만든 뒤 그 주소로 따로 보낸다. 콜백 주소는 **1분 · 1회**만 유효하다. → 콜백이 꺼져 있으면 예전처럼 동기로 답하되 `DEADLINE_SEC` 로 끊는다. 실측(2026-09-22): 필드 43개 + fact 수십 개가 실린 실제 프롬프트는 4초를 넘겼다 — 개발 중 재본 1.3~2.4초는 항목 두 개짜리 장난감 프롬프트였다. """ 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"]) # 콜백이 꺼져 있을 때만 쓰는 상한. 카카오가 5초에 끊으므로 그보다 살짝 앞에서 우리가 끊는다 — # 침묵보다 "잠시 뒤 다시" 가 낫다. DEADLINE_SEC = 4.5 # 콜백이 켜져 있을 때의 상한. 콜백 주소가 1분간 유효하므로 그 안에서 넉넉히 잡는다. 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개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** — # channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다. 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: # 설정이 없으면 이 기능은 존재하지 않는다. 401 로 답하면 엔드포인트의 존재를 알린다. 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: """답을 다 만든 뒤 콜백 주소로 보낸다. ★ 주소는 1분 · 1회만 유효하다. 실패해도 재시도하지 않는다 — 두 번째 POST 는 어차피 거절되고, 사장님에게는 이미 "확인하고 있어요" 가 가 있다.""" 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)