"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**. `version: "2.0"` · `simpleText` · `quickReplies` 같은 모양이 서비스 계층에 새면, 다른 채널을 붙일 때 그걸 전부 걷어내야 한다. 알림톡 어댑터에 건 것과 같은 규칙이다. ★★ **오픈빌더는 서명을 주지 않는다.** URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.user.id` 를 아무 값이나 넣으면 **그 사장님 행세를 한다** — 신원 연결 (`owner_kakao_links`)이 통째로 무의미해진다. 그래서 공유 시크릿을 우리가 직접 댄다. 시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** — 반쯤 열린 상태를 만들지 않는 것은 Threads 연결과 같은 규칙이다. ★ 5초 벽: 오픈빌더는 스킬 서버 응답을 오래 기다리지 않는다. 넘기면 카카오가 연결을 끊고, 사장님에게는 **말없이 실패하는 봇**이 된다. 무거운 잡(BUILD)은 이미 큐에 넣고 즉답하는 구조라 여기 걸리지 않지만, 상한은 명시해 둔다. """ import asyncio import hmac from fastapi import APIRouter, 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"]) # 도구 선택 1콜이 실측 1.3~2.4초다. 4초를 넘기면 답을 포기하고 안내 문구로 끊는다 — # 침묵보다 "잠시 뒤 다시" 가 낫다. DEADLINE_SEC = 4.0 _TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요." _ERROR_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 _handle(body: dict) -> dict: request = body.get("userRequest") or {} utterance = request.get("utterance") or "" speaker = (request.get("user") or {}).get("id") or "" if not speaker: # 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다. return _reply("사용자를 확인하지 못했어요.") try: answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=DEADLINE_SEC) except asyncio.TimeoutError: # ★ 콜백으로 나중에 미는 길은 아직 없다(오픈빌더 지원 여부 확인 필요, docs/AGENT.md). 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")) @router.post("/webhook") async def webhook(request: Request, x_agent_secret: str | None = Header(default=None)): """헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다.""" body = await request.json() _authorize(None, x_agent_secret, body) return await _handle(body) @router.post("/webhook/{secret}") async def webhook_with_path_secret(secret: str, request: Request, x_agent_secret: str | None = Header(default=None)): """헤더를 못 넣는 경우의 대안. ★ 최후 수단이다 — 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 쓸 수 있으면 위를 쓴다.""" body = await request.json() _authorize(secret, x_agent_secret, body) return await _handle(body)