충돌은 이 브랜치의 주석 정리 커밋(11d30bb)과 main 의 기능 변경이 겹친 백엔드 9개 파일 —
main 쪽을 그대로 썼다(기능이 main 에만 있다). AGENTS.md 는 양쪽 링크를 모두 두고,
DEVLOG 는 요약본 위에 main 의 09-28 에이전트 항목 셋을 붙였다.
site eslint 통과 · vitest 123 passed · frontend tsc·eslint 통과 · 백엔드 py_compile 통과
(백엔드 pytest 는 이 기계에 테스트 DB 계정이 없어 실행하지 못함)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
410 lines
19 KiB
Python
410 lines
19 KiB
Python
"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기."""
|
|
|
|
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) -> dict:
|
|
"""대화 한 턴."""
|
|
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("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.")
|
|
|
|
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": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
|
|
}
|