"""카카오톡 채널 발화자를 우리 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.database.model.models import owner_kakao_links as Link from common.enums import 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 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)