"콜백을 켰는데 그대로" 를 로그 없이 추측으로 좁히고 있었다. 스킬이 폴백이 아닌 다른 블록에 붙어 있으면 그 블록에는 콜백 설정이 없어 조용히 동기로 돈다 — 어느 블록이 도는지까지 같이 남긴다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
148 lines
7.3 KiB
Python
148 lines
7.3 KiB
Python
"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**.
|
|
|
|
`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)
|