o2o-site-AEO/solution/backend/services/agent/channel.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

236 lines
11 KiB
Python

"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기.
★★ **카카오를 모른다.** `version: "2.0"` · `simpleText` 같은 형식은 한 글자도 여기 없다.
그건 `router/v1/agent/kakao_bot.py` 안에서 끝난다 — 새면 다른 채널을 붙일 때 전부
걷어내야 하고, 알림톡 어댑터에 건 것과 같은 규칙이다.
★ 빌더 화면과 무엇이 다른가 — 셋뿐이다.
1. 로그인 토큰이 없다 → 연결된 발화자 키로 사장님을 찾는다
2. place_id 가 URL 에 없다 → 대화에서 고르고 기억한다
3. 확인을 되돌려 줄 프론트가 없다 → 무엇을 물었는지 서버가 들고 있는다
나머지(도구·등급·게이트)는 `runtime.chat()` 그대로다.
"""
import re
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import owner_kakao_links as Link
from common.database.model.models import users
from common.enums import DBWRType, ErrorType, KakaoLinkStatus
from common.models.gmodel import UserInfo
from crud.place_crud import PlaceCRUD
from services import kakao_link_service as link_service
from services.agent import runtime
from services.agent.tools import REGISTRY
from services.kakao_link_service import KakaoLinkError
# 연결 코드 모양(kakao_link_service._CODE_ALPHABET 과 같은 글자 집합).
CODE_PATTERN = re.compile(r"[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}")
# 확인 대기 수명. ★ 이게 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
PENDING_MINUTES = 3
# ★ 바로가기 라벨과 '예' 로 읽는 말이 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이
# 고장난 줄 안다. 라벨을 상수로 두고 _YES 가 그것을 포함하게 묶는다.
CONFIRM_LABEL = "네, 해주세요"
PUBLISH_LABEL = "네, 발행해주세요"
DECLINE_LABEL = "아니요"
_YES = {CONFIRM_LABEL, PUBLISH_LABEL, "", "", "", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"}
_NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"}
def _now():
return datetime.now(timezone.utc)
def _say(text: str, quick: list[str] | None = None) -> dict:
"""채널이 모르는 모양으로 답한다 — 문구와 바로가기 목록뿐이다."""
return {"text": text, "quick_replies": quick or []}
async def _user_info(user_id) -> UserInfo | None:
"""user_id → UserInfo. ★ 토큰을 발급하지 않는다.
프로세스 안에서 쓸 객체만 만든다 — 카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취
경로다(docs/AGENT.md)."""
async def run(s):
row = (await s.execute(select(users).where(users.user_id == user_id, users.deleted.is_(False)))).scalars().first()
return ErrorType.SUCCESS, row
# ★ execute_lambda 는 람다 반환값을 **그대로** 준다. CRUD 관례(ErrorType, 값)를 따라
# 우리 람다도 같은 모양으로 돌려준다 — 안 맞추면 여기서 TypeError 로 조용히 죽는다.
err, row = await DB_SESSION_MNG.execute_lambda(users.DBType(), DBWRType.DB_READ.value, run)
if err != ErrorType.SUCCESS or row is None:
return None
return UserInfo(user_id=str(row.user_id), id=row.id, role=row.role, token_version=row.token_version)
async def _link_row(channel_user_key: str):
async def run(s):
row = (
await s.execute(
select(Link).where(
Link.channel_user_key == channel_user_key,
Link.deleted.is_(False),
Link.status == KakaoLinkStatus.LINKED.value,
)
)
).scalars().first()
return ErrorType.SUCCESS, row
_err, row = await DB_SESSION_MNG.execute_lambda(Link.DBType(), DBWRType.DB_READ.value, run)
return row
async def _update_link(channel_user_key: str, **values):
async def run(s):
row = (
await s.execute(
select(Link).where(
Link.channel_user_key == channel_user_key,
Link.deleted.is_(False),
Link.status == KakaoLinkStatus.LINKED.value,
)
)
).scalars().first()
if row is None:
return None
for name, value in values.items():
setattr(row, name, value)
return row
await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
async def _clear_pending(key):
await _update_link(key, pending_tool=None, pending_args=None, pending_expires_at=None)
async def _places(user: UserInfo) -> list:
# ★ (err, rows, total) 셋으로 풀린다 — place_service.list_places 와 같은 호출 모양이다.
err, rows, _total = await DB_SESSION_MNG.execute_lambda(
Link.DBType(),
DBWRType.DB_READ.value,
lambda s: PlaceCRUD().list_places(s, uuid.UUID(user.user_id), None, None, None, 0, 20),
)
return list(rows or []) if err == ErrorType.SUCCESS else []
async def _pick_place(user: UserInfo, row, utterance: str):
"""어느 가게 이야기인지 정한다.
★ 여럿인데 안 정해졌으면 **되묻는다.** 임의로 첫 가게를 고르면, 사장님은 엉뚱한 가게를
고쳐 놓고도 그 사실을 모른다 — 화면과 달리 대화에는 "지금 보고 있는 가게" 가 없다.
반환: (place_id, 되물을 답 or None)"""
places = await _places(user)
if not places:
return None, _say("아직 등록된 가게가 없어요. 홈페이지를 먼저 만들어 주세요.")
names = {p.name.strip(): p for p in places}
# 바로가기를 눌렀거나 가게 이름을 그대로 말한 경우 — 그 가게로 맞춘다.
chosen = names.get(utterance.strip())
if chosen is not None:
await _update_link(row.channel_user_key, current_place_id=chosen.place_id,
pending_tool=None, pending_args=None, pending_expires_at=None)
return None, _say(f"'{chosen.name}' 으로 맞췄습니다. 무엇을 도와드릴까요?")
if len(places) == 1:
if row.current_place_id != places[0].place_id:
await _update_link(row.channel_user_key, current_place_id=places[0].place_id)
return str(places[0].place_id), None
if row.current_place_id is not None:
return str(row.current_place_id), None
return None, _say("어느 가게 이야기일까요?", [p.name for p in places[:10]])
async def handle(utterance: str, channel_user_key: str) -> dict:
"""대화 한 턴. 예외를 던지지 않는다 — 메신저에서는 500 도 침묵으로 보인다."""
utterance = (utterance or "").strip()
if not utterance:
return _say("무엇을 도와드릴까요?")
row = await _link_row(channel_user_key)
# ── 아직 연결되지 않은 발화자 ─────────────────────────────────────────
if row is None:
found = CODE_PATTERN.fullmatch(utterance.upper())
if not found:
return _say("먼저 홈페이지 관리자 화면의 [내 사이트]에서 카카오톡 연결 코드를 받아 보내 주세요.")
try:
await link_service.redeem(utterance, channel_user_key)
except KakaoLinkError:
# ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석).
return _say("코드가 맞지 않거나 시간이 지났어요. 새 코드를 받아 다시 보내 주세요.")
return _say("연결됐습니다. 이제 여기서 홈페이지를 고칠 수 있어요.\n예) 체크인 시간 3시로 바꿔줘")
user = await _user_info(row.user_id)
if user is None:
return _say("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.")
# ── 확인 이어받기 ────────────────────────────────────────────────────
pending = None
if row.pending_tool and row.pending_expires_at and row.pending_expires_at > _now():
pending = {"tool": row.pending_tool, "args": row.pending_args or {}}
elif row.pending_tool:
# 만료. 조용히 흘리지 않고 치운다 — 남아 있으면 다음 "네" 가 그걸 집는다.
await _clear_pending(channel_user_key)
if pending is not None:
if utterance in _YES:
await _clear_pending(channel_user_key)
result = await runtime.chat(user, str(row.current_place_id), "", confirm=pending)
return _say(result["reply"])
if utterance in _NO:
await _clear_pending(channel_user_key)
return _say("알겠습니다. 그대로 두겠습니다.")
# 다른 말을 했으면 그 말이 우선이다. 묵은 확인을 들고 있지 않는다.
await _clear_pending(channel_user_key)
# ── 가게 고르기 ──────────────────────────────────────────────────────
place_id, ask = await _pick_place(user, row, utterance)
if ask is not None:
return ask
# ── 도구 ─────────────────────────────────────────────────────────────
try:
result = await runtime.chat(user, place_id, utterance)
except runtime.AgentError as ex:
return _say(_ERRORS.get(str(ex), "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요."))
if result.get("needs_confirm") and result.get("tool"):
await _update_link(
channel_user_key,
pending_tool=result["tool"],
pending_args=result.get("args") or {},
pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES),
)
return _say(result["reply"], [CONFIRM_LABEL, DECLINE_LABEL])
# 값을 고쳤으면 재발행을 바로 누를 수 있게 바로가기를 붙인다 — 도구가 이미 그렇게 묻는다.
quick = [PUBLISH_LABEL, DECLINE_LABEL] if result.get("done") and result.get("tool") != REGISTRY["publish"].name else []
if quick:
await _update_link(
channel_user_key,
pending_tool="publish",
pending_args={},
pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES),
)
return _say(result["reply"], quick)
_ERRORS = {
"PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.",
"AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.",
"AGENT_MESSAGE_TOO_LONG": "말씀이 조금 길어요. 짧게 나눠서 말씀해 주세요.",
"AGENT_CALL_FAILED": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
}