결정(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. 실제 카톡 수신은 콘솔 설정·운영 배포 뒤에 확인한다.
224 lines
8.9 KiB
Python
224 lines
8.9 KiB
Python
"""카카오톡 채널 발화자를 우리 user_id 에 묶는다 — 에이전트의 모든 도구가 이 매핑 위에 선다.
|
|
|
|
★ 이 파일이 없으면 채널 진입점만 소유자 범위 밖에 놓인다. 다른 엔드포인트는 전부
|
|
place_crud.get_place(s, owner_user_id, place_id) 로 "없는 것과 남의 것을 똑같이
|
|
PLACE_NOT_FOUND 로" 답하는데, 채널에서 온 발화에는 그 owner_user_id 를 줄 근거가
|
|
없다 — 카카오가 주는 것은 **채널 단위 익명 키**뿐이다.
|
|
|
|
★ 일회성은 코드 값이 아니라 `WHERE status='PENDING'` CAS 가 보장한다. 조회 후 갱신으로
|
|
나누면 같은 코드가 두 번 먹는다(승인 흐름이 같은 이유로 한 문장이다).
|
|
"""
|
|
|
|
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
|
|
|
|
# 사장님이 카톡 대화창에 손으로 친다. 혼동하는 글자(0·O·1·I·L)는 뺀다 —
|
|
# 잘못 읽어 실패하면 원인이 화면에 안 보이고 "연결이 안 된다" 로만 보인다.
|
|
_CODE_ALPHABET = "ABCDEFGHJKMNPQRSTUVWXYZ23456789"
|
|
_CODE_LENGTH = 6
|
|
|
|
|
|
class KakaoLinkError(RuntimeError):
|
|
"""도메인 예외. 코드 문자열만 담고 HTTP 변환은 라우터가 한다(social 과 같은 규약)."""
|
|
|
|
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 와 같은 방식).
|
|
|
|
행 잠금이 아니라 advisory 인 이유: PENDING 행이 아직 없을 수도 있어서, 잠글 행 자체가
|
|
없는 순간이 존재한다."""
|
|
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:
|
|
"""일회용 코드를 낸다. 이미 PENDING 이면 **같은 행의 코드만 교체**한다.
|
|
|
|
★ 행을 새로 만들지 않는 이유는 uq_kakao_link_user 때문만이 아니다 — 사장님이 버튼을
|
|
두 번 눌렀을 때 옛 코드가 살아 있으면, 둘 중 어느 것이 먹을지 화면이 말해 줄 수 없다."""
|
|
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 를 돌려준다. 실패는 전부 같은 에러다.
|
|
|
|
★ "없는 코드" 와 "남의 코드" 와 "만료" 를 구분해 답하지 않는다 — 구분해 주면 짧은
|
|
코드의 유효성을 외부에서 탐색할 수 있다.
|
|
★ 아직 공개 엔드포인트가 아니다. 채널 웹훅(4단계)이 이 함수를 부르고, 그 웹훅은
|
|
자체 서명 검증을 따로 갖춰야 한다."""
|
|
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. 매핑이 없으면 None 이고, 호출측은 거기서 멈춰야 한다.
|
|
|
|
★ None 을 "아무 사장님" 으로 흘려보내면 이 기능 전체가 무의미해진다."""
|
|
|
|
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:
|
|
"""연결을 끊는다. 행은 REVOKED 로 남긴다 — 지우면 누가 언제 연결했는지가 사라진다.
|
|
|
|
★ channel_user_key 도 남긴다. 부분 유니크가 status='LINKED' 조건이라 재연결을 막지 않는다."""
|
|
|
|
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)
|