런타임은 한 줄도 안 바뀌었다. 채널을 모르게 만들어 둔 것이 여기서 값을 했다 — 새로 생긴 것은 형식 변환(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>
236 lines
11 KiB
Python
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": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
|
|
}
|