"""카카오톡 채널 발화자를 우리 user_id 에 묶는다 — 에이전트의 모든 도구가 이 매핑 위에 선다.""" import hashlib import secrets from datetime import datetime, timedelta, timezone from uuid import UUID from sqlalchemy import select, text, update from common.database.db_session_manager import DB_SESSION_MNG from common.logger import LOG from common.database.model.models import owner_kakao_links as Link from common.database.model.models import users from common.enums import AuthProvider, ErrorType, KakaoLinkStatus from config import agent_config as config # 사장님이 카톡 대화창에 손으로 친다. _CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789" _CODE_LENGTH = 6 class KakaoLinkError(RuntimeError): """도메인 예외.""" def __init__(self, code="KAKAO_LINK_FAILED"): super().__init__(code) def enabled() -> bool: return config.kakao_link_enabled() def _now(): return datetime.now(timezone.utc) def _sha(code: str) -> str: return hashlib.sha256(code.strip().upper().encode()).hexdigest() def _new_code() -> str: return "".join(secrets.choice(_CODE_ALPHABET) for _ in range(_CODE_LENGTH)) async def _lock_user(s, user_id): """연결·재발급·해제가 같은 잠금을 공유한다(social_account_service.lock_user 와 같은 방식).""" await s.execute( text("SELECT pg_advisory_xact_lock(hashtextextended(:key, 0))"), {"key": f"kakao_link:{user_id}"}, ) async def _active(s, user_id): return ( await s.execute( select(Link).where( Link.user_id == user_id, Link.deleted.is_(False), Link.status.in_([KakaoLinkStatus.PENDING.value, KakaoLinkStatus.LINKED.value]), ) ) ).scalars().first() async def state(user_id: UUID) -> dict: """빌더 카드가 읽는 값.""" async def run(s): row = await _active(s, user_id) return { "connection_enabled": enabled(), "channel_url": config.channel_url(), "status": row.status if row else None, "linked_at": row.linked_at.isoformat() if row and row.linked_at else None, "code_expires_at": ( row.code_expires_at.isoformat() if row and row.status == KakaoLinkStatus.PENDING.value and row.code_expires_at else None ), } return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) async def issue_code(user_id: UUID) -> dict: """일회용 코드를 낸다.""" if not enabled(): raise KakaoLinkError("KAKAO_LINK_DISABLED") code = _new_code() expires = _now() + timedelta(minutes=int(config.get("KAKAO_LINK_CODE_TTL_MIN", 10))) async def run(s): await _lock_user(s, user_id) row = await _active(s, user_id) if row is not None and row.status == KakaoLinkStatus.LINKED.value: raise KakaoLinkError("KAKAO_LINK_ALREADY") if row is None: row = Link(user_id=user_id, status=KakaoLinkStatus.PENDING.value) s.add(row) row.code_sha = _sha(code) row.code_expires_at = expires row.code_attempts = 0 return {"code": code, "expires_at": expires.isoformat(), "channel_url": config.channel_url()} return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) async def redeem(code: str, channel_user_key: str) -> UUID: """채널에서 들어온 코드를 소비하고 user_id 를 돌려준다.""" sha = _sha(code) max_attempts = int(config.get("KAKAO_LINK_MAX_ATTEMPTS", 5)) async def run(s): # 한 문장 CAS. row = ( await s.execute( text("""UPDATE owner_kakao_links SET status='LINKED', channel_user_key=:key, linked_at=now(), last_seen_at=now(), code_sha=NULL, code_expires_at=NULL, updated_at=now() WHERE code_sha=:sha AND deleted=false AND status='PENDING' AND code_expires_at > now() AND code_attempts < :max RETURNING user_id"""), {"sha": sha, "key": channel_user_key, "max": max_attempts}, ) ).first() if row is None: # 맞는 코드가 없으면 셀 행도 없다. await s.execute( text("""UPDATE owner_kakao_links SET code_attempts = code_attempts + 1, updated_at=now() WHERE code_sha=:sha AND deleted=false AND status='PENDING'"""), {"sha": sha}, ) raise KakaoLinkError("KAKAO_LINK_CODE_INVALID") return row.user_id return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) async def link_by_app_user_id(app_user_id: str, channel_user_key: str) -> UUID | None: """카카오 로그인으로 가입한 사장님을 **코드 없이** 채널에 잇는다. ★★ 챗봇 웹훅의 `user.properties.appUserId` 는 **카카오 로그인의 회원번호와 같은 값**이다 (카카오 공식 문서, 봇에 앱키가 물려 있을 때). 그래서 로그인만 해 두면 채널에 말을 거는 순간 누구인지 알 수 있고, 6자리 코드 절차가 필요 없어진다. ★ 그래도 **코드 경로를 지우지 않는다.** id/pw·구글로 가입한 사장님에게는 appUserId 가 없고, 봇에 앱키가 안 물린 환경에서는 이 값 자체가 안 온다 — 그때 유일한 길이다. ★ 이미 그 카톡이 **다른 사장님**에게 묶여 있으면 잇지 않는다(부분 유니크가 막는다). 조용히 빼앗으면 앞사람이 남의 가게를 보게 된다. 반환: 이어진 사장님의 user_id. 못 이으면 None.""" if not app_user_id or not channel_user_key: return None async def run(s): user = ( await s.execute( select(users).where( users.provider == AuthProvider.KAKAO.value, users.provider_uid == str(app_user_id), users.deleted.is_(False), ) ) ).scalars().first() if user is None: return ErrorType.SUCCESS, None await _lock_user(s, user.user_id) taken = ( 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 taken is not None: # 이미 이어져 있으면 그대로 둔다 — 같은 사람이면 성공, 다른 사람이면 빼앗지 않는다. return ErrorType.SUCCESS, (user.user_id if taken.user_id == user.user_id else None) row = await _active(s, user.user_id) if row is None: row = Link(user_id=user.user_id, status=KakaoLinkStatus.LINKED.value) s.add(row) row.status = KakaoLinkStatus.LINKED.value row.channel_user_key = channel_user_key row.linked_at = _now() row.code_sha = None row.code_expires_at = None return ErrorType.SUCCESS, user.user_id _err, user_id = await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) if user_id is not None: LOG.i("[kakao-link] 카카오 로그인 계정과 채널을 자동으로 이었다") return user_id async def resolve(channel_user_key: str) -> UUID | None: """채널 발화자 → user_id.""" 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 row.last_seen_at = _now() return row.user_id return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) async def linked_key(user_id) -> str | None: """사장님에게 연결된 카톡 발화자 키(botUserKey). 연결이 없거나 끊겼으면 None. ★ 승인 알림을 **먼저 보낼** 때 쓴다(Event API). resolve() 와 방향이 반대다 — 그건 들어온 발화자로 사장님을 찾는다. 여기서는 사장님으로 발화자를 찾는다.""" async def run(s): row = ( await s.execute( select(Link).where( Link.user_id == user_id, Link.deleted.is_(False), Link.status == KakaoLinkStatus.LINKED.value, Link.channel_user_key.is_not(None), ) ) ).scalars().first() return row.channel_user_key if row is not None else None return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) async def disconnect(user_id: UUID) -> None: """연결을 끊는다.""" async def run(s): await _lock_user(s, user_id) result = await s.execute( update(Link) .where( Link.user_id == user_id, Link.deleted.is_(False), Link.status.in_([KakaoLinkStatus.PENDING.value, KakaoLinkStatus.LINKED.value]), ) .values(status=KakaoLinkStatus.REVOKED.value, code_sha=None, code_expires_at=None) ) if result.rowcount == 0: raise KakaoLinkError("KAKAO_LINK_NOT_FOUND") await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)