런타임은 한 줄도 안 바뀌었다. 채널을 모르게 만들어 둔 것이 여기서 값을 했다 — 새로 생긴 것은 형식 변환(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>
102 lines
5.0 KiB
Python
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)
|