결정(2026-09-29): 카톡과 메일 둘 다, 승인은 링크가 아니라 연결된 계정 신원으로. 이 커밋은 발송과 메시지 그리기까지고 [승인] 버튼은 다음 단계(2-2)다. - blog_jobs._send_one: 메일에 더해 Event API 로 보낸다. 하나라도 나가면 SENT, 아무 데도 안 나가면 SENT 로 표시하지 않아 다음 스윕이 다시 시도한다. 카톡은 채널 친구가 아니거나 차단했으면 실패하므로 메일을 빼지 않는다. send_now 도 같은 경로 - 카톡 params 로 post_id 와 수정용 일회용 코드(edit_token)를 넘긴다. 코드 평문은 발송 시점에만 알아서다. 로그에는 params 의 키만 남기고 값은 남기지 않는다 - channel.approval_notice: 발화자 키 → 사장님 → 그 글이 그 사장님 가게 것·미처리· 기한 전일 때만 본문과 [수정하기] 를 준다. 연결 안 됨·남의 글·처리됨·만료·이상한 ID 는 구분 없이 같은 안내(구분해 주면 글 ID 를 탐색할 수 있다) - kakao_bot: userRequest.params.post_id 가 있으면 승인 알림 요청으로 처리하고 링크 버튼은 본문과 따로 textCard 로 그린다(카드 설명 길이 제한을 피한다) - KAKAO_APPROVAL_PUSH_ENABLED(기본 0), KAKAO_APPROVAL_EVENT_NAME 추가 — 오픈빌더 이벤트 블록(스킬 연결)과 배포가 끝나기 전에는 켜지 않는다 test_blog_owner 6건·test_kakao_webhook 6건 추가, 카카오·미니블로그 스위트 119 passed, 인접 스위트 110 passed. 실제 카톡 수신은 콘솔 설정·운영 배포 뒤에 확인한다.
182 lines
9.5 KiB
Python
182 lines
9.5 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, links=None) -> dict:
|
|
"""오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다."""
|
|
payload: dict = {"outputs": [{"simpleText": {"text": text}}]}
|
|
if links:
|
|
# 링크 버튼은 카드에만 붙는다(simpleText 에는 버튼이 없다). 본문과 따로 둬 카드 설명 길이
|
|
# 제한(글 문구가 그 안에 안 들어갈 수 있다)에 걸리지 않게 한다.
|
|
payload["outputs"].append({"textCard": {
|
|
"description": "고쳐서 올리려면 아래 버튼을 눌러 주세요.",
|
|
"buttons": [{"label": link["label"], "action": "webLink", "webLinkUrl": link["url"]} for link in links[:3]],
|
|
}})
|
|
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"), answer.get("links"))
|
|
|
|
|
|
async def _approval_notice(params: dict, speaker: str) -> dict:
|
|
"""Event API 로 시작된 승인 알림 요청. 글 ID·수정 코드는 우리가 이벤트를 보낼 때 params 로
|
|
실은 값이고, 누구에게 무엇을 보여줄지는 channel.approval_notice 가 다시 판단한다.
|
|
|
|
★ 5초 벽 안에서 끝나는 DB 조회뿐이라 콜백을 쓰지 않고 바로 답한다."""
|
|
try:
|
|
answer = await asyncio.wait_for(
|
|
channel.approval_notice(speaker, str(params.get("post_id") or ""), params.get("edit_token")),
|
|
timeout=DEADLINE_SEC,
|
|
)
|
|
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"), answer.get("links"))
|
|
|
|
|
|
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("사용자를 확인하지 못했어요.")
|
|
|
|
# ★ 우리가 Event API 로 보낸 승인 알림이 이 요청을 시작시켰다면 params 에 글 ID 가 있다.
|
|
# 값(수정 코드는 비밀에 준한다)은 남기지 않고 어떤 키가 왔는지만 남긴다 — 실제 요청 본문이
|
|
# 문서와 같은지를 눈으로 가릴 수 있게.
|
|
params = request.get("params") or {}
|
|
if params.get("post_id"):
|
|
LOG.i(f"[agent/kakao] 승인 알림 요청 — params={sorted(params)} block={(request.get('block') or {}).get('name')!r}")
|
|
return await _approval_notice(params, speaker)
|
|
|
|
# ★ 콜백이 켜져 있으면 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)
|