o2o-site-AEO/solution/backend/services/agent/channel.py
hbyang ca0bea77a7 [feat] solution/backend: 카카오 로그인 — 회원번호로 카톡 채널을 코드 없이 잇는다
★★ 챗봇 웹훅의 user.properties.appUserId 는 카카오 로그인의 회원번호와 **같은 값**이다
(카카오 공식 문서, 봇에 앱키가 물려 있을 때). 그래서 카카오로 로그인만 해 두면 채널에
말을 거는 순간 누구인지 알 수 있고, 6자리 코드 절차가 필요 없어진다.

- external/kakao_identity: 액세스 토큰을 카카오에 되물어 확인한다. ★ 응답의 app_id 를
  우리 앱과 대조하는 것이 구글의 aud 검사에 해당한다 — 이게 없으면 남의 앱 토큰으로
  우리 계정이 된다. 이름·이메일은 동의 항목이라 못 받아도 로그인은 되게 했다
- auth_service.kakao_login: google_login 과 같은 세 갈래. 이메일이 겹쳐도 자동으로
  잇지 않는다(DECISIONS 1 — 계정 선점)
- kakao_link_service.link_by_app_user_id: 자동 매칭. ★ 이미 다른 사장님에게 묶인
  카톡은 빼앗지 않는다 — 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다
- ★ 코드 경로는 그대로 둔다: id/pw·구글 가입자에겐 appUserId 가 없고, 봇에 앱키가
  안 물린 환경에서는 값 자체가 안 온다

★ 함께 고친 것 — services/agent/tools.py 가 사라진 site_payload._DEFAULT_THEME 를
보고 있었다(c690862 템플릿 정의 통합에서 이름이 없어졌는데 이 한 줄만 남았다).
**대화의 섹션 기능이 통째로 죽어 있었고** 웹훅이 AttributeError 를 삼켜 "지금은
처리할 수 없어요" 로만 보였다 — common/template_catalog.industry_of 로 바꿨다.

test_kakao_link·test_kakao_webhook·test_agent_runtime·test_auth 194 passed
(신규 4: 자동 매칭·미가입자·빼앗지 않음·재진입). 남은 1건은 컨테이너에 실제
GOOGLE_CLIENT_ID 가 있어 나는 기존 실패다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-30 16:27:54 +09:00

418 lines
20 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, 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": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
}