여러 줄 주석이 설명보다 경위(예전·실측·지적)를 적고 있어 읽는 사람이 결론을 찾기 어려웠다. - ts·tsx·js·mjs·css·py 478개: 여러 줄 주석은 첫 문장 한 줄로, 과거형·날짜 문장은 삭제 - 주석 위치는 TypeScript 파서·파이썬 tokenize/ast 로 찾는다 — 문자열 안의 # · /* 는 건드리지 않는다 - eslint·ts·noqa·type: ignore 같은 지시 주석은 그대로 둔다 파이썬 275개 정리 전후 AST 동일, TS 298개 주석 뺀 토큰 동일(빈 JSX 주석 10곳만 차이). site·frontend·admin tsc, site vitest 105 passed Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
121 lines
4.7 KiB
Python
121 lines
4.7 KiB
Python
"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**."""
|
|
|
|
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"])
|
|
|
|
# 콜백이 꺼져 있을 때만 쓰는 상한.
|
|
DEADLINE_SEC = 4.5
|
|
# 콜백이 켜져 있을 때의 상한.
|
|
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개.
|
|
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:
|
|
# 설정이 없으면 이 기능은 존재하지 않는다.
|
|
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:
|
|
"""답을 다 만든 뒤 콜백 주소로 보낸다."""
|
|
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)
|