결정(2026-09-29): 카톡과 메일 둘 다, 승인은 링크가 아니라 연결된 계정 신원으로. 이 커밋은 발송과 메시지 그리기까지고 [승인] 버튼은 다음 단계(2-2)다. - blog_jobs._send_one: 메일에 더해 Event API 로 보낸다. 하나라도 나가면 SENT, 아무 데도 안 나가면 SENT 로 표시하지 않아 다음 스윕이 다시 시도한다. 카톡은 채널 친구가 아니거나 차단했으면 실패하므로 메일을 빼지 않는다. send_now 도 같은 경로 - 카톡 params 로 post_id 와 수정용 일회용 코드(edit_token)를 넘긴다. 코드 평문은 발송 시점에만 알아서다. 로그에는 params 의 키만 남기고 값은 남기지 않는다 - channel.approval_notice: 발화자 키 → 사장님 → 그 글이 그 사장님 가게 것·미처리· 기한 전일 때만 본문과 [수정하기] 를 준다. 연결 안 됨·남의 글·처리됨·만료·이상한 ID 는 구분 없이 같은 안내(구분해 주면 글 ID 를 탐색할 수 있다) - kakao_bot: userRequest.params.post_id 가 있으면 승인 알림 요청으로 처리하고 링크 버튼은 본문과 따로 textCard 로 그린다(카드 설명 길이 제한을 피한다) - KAKAO_APPROVAL_PUSH_ENABLED(기본 0), KAKAO_APPROVAL_EVENT_NAME 추가 — 오픈빌더 이벤트 블록(스킬 연결)과 배포가 끝나기 전에는 켜지 않는다 test_blog_owner 6건·test_kakao_webhook 6건 추가, 카카오·미니블로그 스위트 119 passed, 인접 스위트 110 passed. 실제 카톡 수신은 콘솔 설정·운영 배포 뒤에 확인한다.
358 lines
17 KiB
Python
358 lines
17 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 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.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, links: list[dict] | None = None) -> dict:
|
|
"""채널이 모르는 모양으로 답한다 — 문구, 바로가기 목록, 링크 버튼({label, url})뿐이다."""
|
|
return {"text": text, "quick_replies": quick or [], "links": links 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)
|
|
|
|
|
|
# 승인 알림에서 보여줄 수 있는 글 = 아직 사이트에 안 올린 글. 메일 승인 링크와 같은 범위다
|
|
# (검수 통과 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='')}"}]
|
|
return _say(f"[{name}] 이번 글을 올릴까요?\n\n{post.body}", links=links)
|
|
|
|
|
|
_ERRORS = {
|
|
"PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.",
|
|
"AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.",
|
|
"AGENT_MESSAGE_TOO_LONG": "말씀이 조금 길어요. 짧게 나눠서 말씀해 주세요.",
|
|
"AGENT_CALL_FAILED": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
|
|
}
|