o2o-site-AEO/solution/backend/router/v1/agent/kakao_bot.py
hbyang d32df10cf7 [feat] solution/backend: 카카오 채널 웹훅 — 에이전트 4단계
런타임은 한 줄도 안 바뀌었다. 채널을 모르게 만들어 둔 것이 여기서 값을 했다 —
새로 생긴 것은 형식 변환(kakao_bot)과 대화 상태(channel)뿐이다.

★★ 오픈빌더는 서명을 주지 않는다. URL 만 알면 누구나 때릴 수 있고
userRequest.user.id 를 위조하면 그 사장님 행세를 한다 — 1단계의 신원 연결이
통째로 무의미해지는 자리다. 공유 시크릿(헤더 X-Agent-Secret, compare_digest)
+ 선택적 KAKAO_BOT_ID 대조로 막고, 시크릿이 없으면 엔드포인트가 404 다
(401 은 "여기 뭔가 있다" 를 알려 준다).

- router/v1/agent/kakao_bot: 카카오 형식을 아는 유일한 파일. 헤더·경로 두 경로
- services/agent/channel: 신원(★ 토큰을 발급하지 않는다) · 가게 고르기 · 확인
- 0022: owner_kakao_links 에 current_place_id · pending_*

빌더 화면과 다른 것 셋:
- 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다
- place_id 가 URL 에 없다 → 여럿이면 추측하지 않고 되묻는다. 임의로 첫 가게를
  고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다
- 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 든다. ★ 3분 만료가 없으면
  한참 뒤의 "네" 한 마디에 묵은 발행이 돈다

5초 벽은 DEADLINE_SEC=4.0 으로 끊고, 어떤 실패도 200+안내다 —
메신저에서는 500 도 침묵으로 보인다.

밟은 것: execute_lambda 는 람다 반환값을 그대로 준다(CRUD 관례가 (ErrorType,값)).
우리 람다가 객체만 돌려주자 언패킹 TypeError 가 났고, 라우터가 예외를 삼켜
화면에는 안내 한 줄만 보였다 — 원인이 안 보이는 종류다.

test_kakao_webhook.py 17 passed. 전체 841 passed / 53 failed(이전과 동일).
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:13:33 +09:00

102 lines
5.0 KiB
Python

"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**.
`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)