★★ 챗봇 웹훅의 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>
260 lines
9.5 KiB
Python
260 lines
9.5 KiB
Python
"""카카오톡 채널 발화자를 우리 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)
|