"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기. ★★ **카카오를 모른다.** `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 crud.site_crud import SiteCRUD from crud.job_crud import JobQueue from common.enums import SiteStatus from common.models.gmodel import PageParams from services.site_service import SiteService 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, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"} # 언제든 목록으로 돌아오는 말. ★ LLM 을 부르지 않는다 — 목록 보기에 돈을 쓸 이유가 없고, # "지금 어느 가게냐" 는 대화가 막혔을 때 가장 먼저 찾는 길이라 늘 통해야 한다. _LIST_WORDS = { "목록", "가게 목록", "사이트 목록", "내 사이트", "홈페이지 목록", "가게 바꿔줘", "가게 변경", "다른 가게", "사이트 바꿔줘", "사이트 변경", } 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 _sites(user: UserInfo) -> list: """사장님의 가게 + 그 사이트 상태를 한 번에. ★ 사업장 목록이 아니라 **사이트 목록**을 쓴다. 대화에서 사장님이 알아야 하는 것은 "가게가 있다" 가 아니라 "발행돼 있나 · 주소가 뭔가" 다 — `/sites` 화면이 같은 이유로 `list_my_sites` 를 쓴다.""" service = SiteService(SiteCRUD(), PlaceCRUD(), JobQueue()) res = await service.list_my_sites(user, PageParams(page=1, size=20)) return list(res.sites or []) def _line(row) -> str: """목록 한 줄. ★ 발행 여부를 같이 말한다 — 안 그러면 사장님은 고친 것이 손님에게 보이는 줄 안다.""" if row.status == SiteStatus.PUBLISHED and row.published_at: when = row.published_at.strftime("%m월 %d일") return f"· {row.name} — {when} 발행" return f"· {row.name} — 아직 발행 전" def _list_reply(rows: list, head: str) -> dict: body = "\n".join(_line(r) for r in rows[:10]) more = f"\n(그 밖에 {len(rows) - 10}곳 더)" if len(rows) > 10 else "" tail = "\n\n어느 가게 이야기일까요?" if len(rows) > 1 else "" return _say(f"{head}\n{body}{more}{tail}", [r.name for r in rows[:10]] if len(rows) > 1 else []) async def _pick_place(user: UserInfo, row, utterance: str): """어느 가게 이야기인지 정한다. ★ 여럿인데 안 정해졌으면 **되묻는다.** 임의로 첫 가게를 고르면, 사장님은 엉뚱한 가게를 고쳐 놓고도 그 사실을 모른다 — 화면과 달리 대화에는 "지금 보고 있는 가게" 가 없다. 반환: (place_id, 되물을 답 or None)""" rows = await _sites(user) if not rows: return None, _say("아직 등록된 가게가 없어요. 홈페이지를 먼저 만들어 주세요.") # ★ 언제든 목록으로 돌아올 수 있어야 한다. 대화가 막혔을 때 처음 찾는 길이다. if utterance in _LIST_WORDS: await _update_link(row.channel_user_key, current_place_id=None, pending_tool=None, pending_args=None, pending_expires_at=None) return None, _list_reply(rows, "관리 중인 홈페이지입니다.") # 바로가기를 눌렀거나 가게 이름을 그대로 말한 경우 — 그 가게로 맞춘다. chosen = {r.name.strip(): r for r in rows}.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}' 으로 맞췄습니다. 무엇을 도와드릴까요?\n" f"예) 체크인 시간 3시로 바꿔줘") if len(rows) == 1: if row.current_place_id != rows[0].place_id: await _update_link(row.channel_user_key, current_place_id=rows[0].place_id) return str(rows[0].place_id), None if row.current_place_id is not None: return str(row.current_place_id), None return None, _list_reply(rows, "관리 중인 홈페이지입니다.") 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: user_id = await link_service.redeem(utterance, channel_user_key) except KakaoLinkError: # ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석). return _say("코드가 맞지 않거나 시간이 지났어요. 새 코드를 받아 다시 보내 주세요.") # ★ 연결만 알리고 끝내지 않는다. 사장님은 **어느 홈페이지를 다루는 대화인지** 모른 채 # 말을 걸게 되고, 가게가 둘 이상이면 첫 마디부터 되묻기에 걸린다. user = await _user_info(user_id) rows = await _sites(user) if user else [] if not rows: return _say("연결됐습니다. 아직 등록된 가게가 없어요 — 홈페이지를 먼저 만들어 주세요.") if len(rows) == 1: await _update_link(channel_user_key, current_place_id=rows[0].place_id) return _say( f"연결됐습니다. '{rows[0].name}' 홈페이지를 여기서 고칠 수 있어요.\n" f"{_line(rows[0])}\n\n예) 체크인 시간 3시로 바꿔줘" ) return _list_reply(rows, "연결됐습니다. 관리 중인 홈페이지입니다.") user = await _user_info(row.user_id) if user is None: return _say("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.") # ★ 이미 연결된 사람이 코드를 또 보내는 일이 실제로 있었다(2026-09-22). 그대로 두면 # 6자리가 그냥 발화로 모델에 넘어가 유료 호출 + 대기만 쌓인다 — 여기서 끊는다. if CODE_PATTERN.fullmatch(utterance.upper()): return _say("이미 연결되어 있어요. 바로 말씀하시면 됩니다.\n예) 체크인 시간 3시로 바꿔줘") # ── 확인 이어받기 ──────────────────────────────────────────────────── 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": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.", }