"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기.""" import re import uuid from datetime import datetime, timedelta, timezone from urllib.parse import quote 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 place_posts, places, users from common.enums import DBWRType, ErrorType, KakaoLinkStatus, PostStatus 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 blog_service, kakao_link_service as link_service from services.agent import runtime from services.post_service import PostService 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 # 바로가기 라벨과 '예' 로 읽는 말이 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 고장난 줄 안다. CONFIRM_LABEL = "네, 해주세요" PUBLISH_LABEL = "네, 발행해주세요" DECLINE_LABEL = "아니요" _YES = {CONFIRM_LABEL, PUBLISH_LABEL, "네", "예", "응", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"} _NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"} # 언제든 목록으로 돌아오는 말. _LIST_WORDS = { "목록", "가게 목록", "사이트 목록", "내 사이트", "홈페이지 목록", "가게 바꿔줘", "가게 변경", "다른 가게", "사이트 바꿔줘", "사이트 변경", } def _now(): return datetime.now(timezone.utc) def _say(text: str, quick: list[str] | None = None, links: list[dict] | None = None, approve_post_id: str | None = None, hint: str | None = None) -> dict: """채널이 모르는 모양으로 답한다 — 문구, 바로가기 목록, 링크 버튼({label, url}), 승인 버튼을 달 글 ID, 버튼 위에 붙는 한 줄 안내뿐이다. 버튼을 어떻게 그릴지는 채널 몫이다.""" return { "text": text, "quick_replies": quick or [], "links": links or [], "approve_post_id": approve_post_id, "hint": hint, } async def _user_info(user_id) -> UserInfo | None: """user_id → UserInfo.""" 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 는 람다 반환값을 **그대로** 준다. 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: """사장님의 가게 + 그 사이트 상태를 한 번에.""" 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): """어느 가게 이야기인지 정한다.""" 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 # ★ 기억한 가게를 목록과 대조한다. 그 가게가 없어졌는데 그대로 쓰면 매번 # "그 가게를 찾지 못했어요" 만 돌아오고, 사장님은 '목록' 을 쳐야 풀린다는 걸 모른다. current = next((r for r in rows if r.place_id == row.current_place_id), None) if current is not None: other = _other_named(rows, current, utterance) if other is not None: return None, _say( f"지금은 '{current.name}' 홈페이지를 고치고 있어요. " f"'{other.name}' 을(를) 고치시려면 먼저 골라 주세요 — 고른 뒤 다시 말씀해 주시면 됩니다.", [other.name, current.name], ) return str(current.place_id), None if row.current_place_id is not None: await _update_link(row.channel_user_key, current_place_id=None) return None, _list_reply(rows, "관리 중인 홈페이지입니다.") def _other_named(rows: list, current, utterance: str): """발화에 **지금 가게가 아닌** 내 가게 이름이 나오면 그 가게. ★ 프롬프트에는 지금 가게 하나만 실린다. "둘째가게 휴무 바꿔줘" 를 그대로 넘기면 첫째가게가 바뀌고, 사장님은 둘째가게가 바뀐 줄 안다 — 모델에 맡기지 않고 여기서 끊는다. ★ 지금 가게 이름에 들어 있는 이름은 보지 않는다('스테이' 와 '스테이 군산점') — 지금 가게를 부른 말인지 가려낼 수 없다.""" mine = (current.name or "").strip() for r in rows: name = (r.name or "").strip() if r.place_id == current.place_id or len(name) < 2 or name in mine: continue if name in utterance: return r return None async def handle(utterance: str, channel_user_key: str, app_user_id: str = '') -> dict: """대화 한 턴.""" utterance = (utterance or "").strip() if not utterance: return _say("무엇을 도와드릴까요?") row = await _link_row(channel_user_key) # ★ 카카오 로그인으로 가입한 사장님은 **코드 없이** 이어진다 — appUserId 가 로그인 # 회원번호와 같은 값이라 그 자리에서 알아볼 수 있다(kakao_link_service 주석). # ★ 코드 경로를 지우지 않는다: id/pw·구글 가입자에게는 이 값이 없고, 봇에 앱키가 # 안 물린 환경에서는 아예 오지 않는다 — 그때 유일한 길이다. if row is None and app_user_id: if await link_service.link_by_app_user_id(app_user_id, channel_user_key): 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("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.") 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) try: result = await runtime.chat(user, str(row.current_place_id), "", confirm=pending) except runtime.AgentError as ex: # 아래 도구 경로와 같은 말로 옮긴다 — "처리할 수 없어요" 로는 원인을 모른다. return _say(_ERRORS.get(str(ex), "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.")) 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) # 승인 알림에서 보여줄 수 있는 글 = 아직 사이트에 안 올린 글. 메일 승인 링크와 같은 범위다 # (검수 통과 REVIEWED · 메일/카톡이 나간 SENT). _APPROVABLE = (PostStatus.REVIEWED.value, PostStatus.SENT.value) async def _owned_pending_post(user_id, post_id: str): """(글, 가게 이름) — **이 사장님 가게의**, 아직 안 올린, 기한이 안 지난 글일 때만. 아니면 None. ★ 글 ID 는 카카오가 이벤트 params 로 실어 온 값이지만 믿지 않는다. 발화자 키 → user_id 로 찾은 사장님이 그 글이 달린 가게의 주인인지를 여기서 다시 본다 — 글 ID 만 알면 남의 글이 보이는 구멍이 되면 안 된다(없는 글과 남의 글을 같은 답으로 돌려준다).""" try: pid = uuid.UUID(str(post_id)) except ValueError: return None async def run(s): row = ( await s.execute( select(place_posts, places.name) .join(places, places.place_id == place_posts.place_id) .where( place_posts.post_id == pid, place_posts.deleted.is_(False), place_posts.status.in_(_APPROVABLE), places.owner_user_id == user_id, places.deleted.is_(False), ) ) ).first() return ErrorType.SUCCESS, row _err, row = await DB_SESSION_MNG.execute_lambda(place_posts.DBType(), DBWRType.DB_READ.value, run) if row is None: return None post, name = row expires = post.token_expires_at if expires is not None: # 메일 링크와 같은 만료(그날 자정 KST). DB 값이 tz 없이 오면 UTC 로 본다. if expires.tzinfo is None: expires = expires.replace(tzinfo=timezone.utc) if expires < _now(): return None return post, name async def approval_notice(channel_user_key: str, post_id: str, edit_token: str | None = None) -> dict: """Event API 로 시작된 승인 알림을 그린다 — 글 문구와 [수정하기] 링크. ★ 연결된 본인의 글이 아니면 본문을 한 글자도 주지 않는다. 연결 안 된 발화자·남의 글· 이미 처리한 글·기한 지난 글은 구분하지 않고 같은 안내로 답한다(구분해 주면 글 ID 를 탐색할 수 있다). 예외를 던지지 않는 것은 handle() 과 같은 규약이다.""" row = await _link_row(channel_user_key) if row is None: return _say("연결된 계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.") found = await _owned_pending_post(row.user_id, post_id) if found is None: return _say("이미 처리했거나 기한이 지난 글이에요.") post, name = found links = [] if edit_token: # 메일의 '고쳐서 올리려면' 과 같은 일회용 코드다 — 어느 쪽이든 먼저 누른 쪽이 쓴다. links = [{"label": "수정하기", "url": f"{blog_service.app_origin()}/v1/site/post/edit?t={quote(edit_token, safe='')}"}] # 승인 버튼은 글 ID 만 실어 돌려주고, 그리는 건 채널 몫이다(블록 ID 같은 카카오 값이 여기 없다). return _say( f"[{name}] 이번 글을 올릴까요?\n\n{post.body}", links=links, approve_post_id=str(post.post_id), hint="고쳐서 올리려면 아래 버튼을 눌러 주세요.", # 승인 버튼이 붙으면 채널이 문구를 바꾼다 ) async def approve_post(channel_user_key: str, post_id: str) -> dict: """카톡 [승인] 버튼 — 연결된 본인의 글일 때만 올린다. ★ 버튼이 들고 온 post_id 를 믿지 않는다. 발화자 키 → 사장님으로 찾은 뒤 그 글이 그 사장님 가게의 미처리·기한 전 글인지를 다시 본다(approval_notice 와 같은 관문). 틀리면 아무것도 승인하지 않고, 어느 쪽이 틀렸는지 구분하지 않고 같은 안내로 답한다. ★ 승인 자체는 메일 링크·빌더 '바로 발행' 과 같은 PostService.approve_by_owner 다 — 재발행 잡과 쓰레드 공유까지 세 경로가 똑같이 탄다. 이미 올린 글을 또 누르면 위 관문에서 걸려 두 번 올라가지 않는다. 예외를 던지지 않는 것은 handle() 과 같은 규약이다.""" row = await _link_row(channel_user_key) if row is None: return _say("연결된 계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.") found = await _owned_pending_post(row.user_id, post_id) if found is None: return _say("이미 처리했거나 기한이 지난 글이에요.") user = await _user_info(row.user_id) if user is None: return _say("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.") post, name = found service = PostService() result = await service.approve_by_owner(user, str(post.place_id), str(post.post_id)) if not result.result.success: return _say("지금은 올리지 못했어요. 잠시 뒤 다시 눌러 주세요.") # 재발행은 몇 분 걸린다 — 메일 승인 확인 화면과 같이, 어디로 가면 보이는지를 바로 알려준다. url = await service.blog_url(post.place_id) links = [{"label": "사이트 보기", "url": url}] if url else [] return _say( f"[{name}] 올렸습니다.\n사이트에 반영되기까지 몇 분 걸려요.", links=links, hint="반영되면 아래 버튼으로 확인해 보세요.", ) _ERRORS = { "PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.", "AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.", "AGENT_MESSAGE_TOO_LONG": "말씀이 조금 길어요. 짧게 나눠서 말씀해 주세요.", "AGENT_CALL_FAILED": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.", }