o2o-site-AEO/docs/AGENT.md
hbyang d32df10cf7 [feat] solution/backend: 카카오 채널 웹훅 — 에이전트 4단계
런타임은 한 줄도 안 바뀌었다. 채널을 모르게 만들어 둔 것이 여기서 값을 했다 —
새로 생긴 것은 형식 변환(kakao_bot)과 대화 상태(channel)뿐이다.

★★ 오픈빌더는 서명을 주지 않는다. URL 만 알면 누구나 때릴 수 있고
userRequest.user.id 를 위조하면 그 사장님 행세를 한다 — 1단계의 신원 연결이
통째로 무의미해지는 자리다. 공유 시크릿(헤더 X-Agent-Secret, compare_digest)
+ 선택적 KAKAO_BOT_ID 대조로 막고, 시크릿이 없으면 엔드포인트가 404 다
(401 은 "여기 뭔가 있다" 를 알려 준다).

- router/v1/agent/kakao_bot: 카카오 형식을 아는 유일한 파일. 헤더·경로 두 경로
- services/agent/channel: 신원(★ 토큰을 발급하지 않는다) · 가게 고르기 · 확인
- 0022: owner_kakao_links 에 current_place_id · pending_*

빌더 화면과 다른 것 셋:
- 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다
- place_id 가 URL 에 없다 → 여럿이면 추측하지 않고 되묻는다. 임의로 첫 가게를
  고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다
- 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 든다. ★ 3분 만료가 없으면
  한참 뒤의 "네" 한 마디에 묵은 발행이 돈다

5초 벽은 DEADLINE_SEC=4.0 으로 끊고, 어떤 실패도 200+안내다 —
메신저에서는 500 도 침묵으로 보인다.

밟은 것: execute_lambda 는 람다 반환값을 그대로 준다(CRUD 관례가 (ErrorType,값)).
우리 람다가 객체만 돌려주자 언패킹 TypeError 가 났고, 라우터가 예외를 삼켜
화면에는 안내 한 줄만 보였다 — 원인이 안 보이는 종류다.

test_kakao_webhook.py 17 passed. 전체 841 passed / 53 failed(이전과 동일).
npm run lint 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-22 15:13:33 +09:00

13 KiB

사장님 에이전트 — 신원 연결 · 도구 · 런타임

사장님이 말로 사이트를 운영하는 것이 목표다 — 내용 고치기, 사진 내리기, 발행, SNS 게재까지. 에이전트는 카카오톡 안에 있지 않다. 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도 붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.

1단계(신원 연결) · 2단계(도구·런타임·빌더 채팅창) · 4단계(카카오 웹훅) 을 만들었다. 남은 것은 오픈빌더 챗봇 등록(우리가 못 하는 일)과 도구 늘리기다.

화면 스위치

화면 여는 조건 지금
대화창(AgentChatDock) AGENT_CHAT_ENABLED=1(기본) 그리고 LLM 키 열림
연결 카드(KakaoChannelCard) KAKAO_CHANNEL_PUBLIC_ID 가 채워짐 채널 ID 미설정

★ 스위치와 키를 둘 다 본다(runtime.is_configured). 키만 보면 "잠시 닫아 두기" 를 키를 지워서 해야 하고 그러면 소개문·사진분류까지 같이 꺼진다. 스위치만 보면 키 없는 환경에서 눌러도 안 되는 입구가 생긴다.

★ 2026-09-21 에 카카오 채널 개설이 법인폰 본인인증에 걸려 한 번 닫았고, 인증이 끝나 2026-09-22 에 다시 열었다. 그때도 코드는 지우지 않고 값만 바꿨다 — 닫고 여는 일이 커밋을 되짚는 일이 되면 안 된다.

★ 연결 카드를 '감추는' 쪽으로 둔 것은 Threads 카드('자리는 두고 버튼만 죽인다')와 반대 판단인데 의도한 차이다. 저쪽은 사장님이 곧 쓸 수 있는 기능이라 존재를 알려야 했고, 이쪽은 웹훅(4단계)이 없어 아직 연결이 완성되지 않는다.

왜 신원 연결이 먼저인가

카카오 채널이 주는 발화자 식별자는 채널 단위 익명 키다. 우리 user_id 와 아무 관계가 없다.

이 레포의 모든 엔드포인트는 place_crud.get_place(s, owner_user_id, place_id) 로 "없는 것과 남의 것을 똑같이 PLACE_NOT_FOUND 로 답하는" 관례를 지킨다. 채널에서 온 발화에는 그 owner_user_id 를 줄 근거가 없다 — 연결 절차가 없으면 채널 진입점만 소유자 범위 밖에 놓이고, 채널에 말을 건 아무나가 남의 가게를 고친다.

절차 — 사장님은 두 번 누른다

  1. /sites 내 사이트 화면의 카카오톡으로 관리 · 채널 연결 카드 → [카카오톡 연결]
  2. 화면에 뜬 6자리 코드를 카카오톡 채널에 보낸다

연결 버튼을 사업장 화면에 두지 않는다. 연결은 user 단위인데 버튼이 사업장 안에 있으면 사장님은 업장마다 연결해야 하는 줄 안다(SocialConnectionCard 가 같은 이유로 거기 있다).

KAKAO_CHANNEL_PUBLIC_ID 가 비면 카드는 그리되 버튼이 죽는다. 어디에 코드를 칠지 말해 줄 수 없는데 코드만 발급하면 사장님에게는 고장난 화면이다. 숨기지는 않는다 — 숨기면 기능이 없는 것처럼 보인다(2026-09-14 Threads 카드에서 실제로 겪었다).

user_id · channel_user_key · code_sha · code_expires_at · code_attempts · status · linked_at · last_seen_at

인덱스 무엇을 막나
uq_kakao_link_user (PENDING·LINKED) 한 사장님에 활성 연결 하나. 다시 눌러도 행이 늘지 않고 코드만 바뀐다
uq_kakao_link_channel_key (LINKED) ★ 한 카카오 계정은 한 사장님에만. 없으면 "어느 가게 이야기냐" 가 대화가 아니라 DB 에서 갈라진다
uq_kakao_link_code (PENDING) 코드 한 행 지목

코드는 평문으로 저장하지 않는다(code_sha). 사장님이 손으로 치는 짧은 값이라, 평문이면 DB 를 읽을 수 있는 쪽이 곧 연결 권한을 갖는다. 그래서 화면에 한 번 뜨고 다시 볼 수 없다 — 카드는 항상 [코드 다시 받기] 를 함께 둔다.

코드 글자에서 0·O·1·I·L 을 뺐다. 잘못 읽어 실패하면 원인이 화면에 안 보이고 "연결이 안 된다" 로만 보인다.

일회성은 값이 아니라 CAS 가 보장한다

UPDATE owner_kakao_links
   SET status='LINKED', channel_user_key=:key, linked_at=now(), code_sha=NULL
 WHERE code_sha=:sha AND deleted=false AND status='PENDING'
   AND code_expires_at > now() AND code_attempts < :max
RETURNING user_id;

조회 후 갱신으로 나누면 같은 코드가 두 번 먹는다(승인 흐름이 같은 이유로 한 문장이다).

실패는 전부 같은 에러다(KAKAO_LINK_CODE_INVALID). "없는 코드"·"만료"·"시도 초과" 를 구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다.

코드는 웹훅에서만 소비된다

redeem()공개 라우터에 붙어 있지 않다. 시크릿 검증을 통과한 웹훅 안에서만 불린다 — 검증 없는 공개 소비 경로가 있으면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다.

API

메서드/경로 역할
GET /v1/agent/kakao/link 연결 상태. ★ 코드 평문은 주지 않는다
POST /v1/agent/kakao/link/code 일회용 코드 발급. 평문은 이 응답에서 한 번만
POST /v1/agent/kakao/link/disconnect 해제. 행은 REVOKED 로 남긴다

셋 다 Cache-Control: no-store · Referrer-Policy: no-referrer · X-Robots-Tag: noindex 다.

설정

KAKAO_CHANNEL_PUBLIC_ID=      # 비면 연결 기능이 꺼진다(카드는 보이고 버튼만 죽는다)
KAKAO_LINK_CODE_TTL_MIN=10
KAKAO_LINK_MAX_ATTEMPTS=5

config/agent_config.pysocial_config.py일부러 갈랐다. SNS 게재는 되돌릴 수 없는 대외 발화이고, 에이전트는 사장님이 자기 사이트를 고치는 창구다. 한 파일에 섞이면 "이 값이 무엇을 여는가" 가 흐려진다.


2단계 — 도구 · 런타임 · 빌더 채팅창

/sites 화면 오른쪽 아래 [말로 고치기] 를 누르면 대화창이 열린다. 카카오 심사 없이 에이전트 전체가 여기서 검증된다.

router/v1/agent/chat.py          빌더 화면 입구
router/v1/social/kakao_bot.py    (4단계) 카톡 입구 — 같은 runtime.chat() 을 부른다
        ↓
services/agent/runtime.py        발화 → 도구 선택 → 실행 → 응답. ★ 채널을 모른다
services/agent/tools.py          레지스트리 — 할 수 있는 일의 전부 + 등급
        ↓
services/fact_service.py · site_service.py      ★ 게이트가 사는 곳

services/prompts/agent.py 가 "무엇을 묻는가" 를 갖는다(LLM 네 겹 규약, services/llm/__init__.py).

도구와 등급

등급 도구 대화에서
READ get_site_status · list_facts 바로 답한다
REVERSIBLE set_fact 실행하고 알린다
SEMI publish 실행 전에 한 번 묻는다

등급은 레지스트리가 못 박는다. 모델이 정하게 두면 프롬프트에 끼어든 한 줄이 확인 절차를 건너뛴다. 그래서 응답 스키마에 등급 칸 자체가 없고, 도구 목록에도 등급을 싣지 않는다.

결과 문구는 도구가 만든다. LLM 이 쓰게 두면 하지 않은 일을 했다고 말할 수 있고, 사장님에게는 그 말이 사실로 보인다. 모델 문장은 '되묻기' 에만 쓴다.

값을 고치면 재발행 안내를 함께 낸다. fact 는 바뀌어도 사이트는 안 바뀐다 — 이 한 줄이 빠지면 사장님은 반영된 줄 알고 확인하러 갔다가 옛 값을 보고 "고장났네" 가 된다.

모호하면 실행하지 않고 되묻는다. 티오더가 "유사한 메뉴가 2개 이상이면 후보 목록을 제시" 로 푼 문제와 같다 — 추측으로 고르면 사장님이 그걸 못 알아채고 넘어간다.

확인(SEMI) 한 바퀴

  1. 발화 → 런타임이 publish 를 고른다 → 실행하지 않고 needs_confirm=true + 확인 문구
  2. 화면이 [네, 해주세요] 를 띄운다
  3. 누르면 {confirm:{tool,args}} 로 다시 POST → LLM 을 부르지 않고 그 도구를 실행

★ 서버는 돌아온 값을 믿지 않는다. 도구 이름은 레지스트리에서 다시 찾고, 인자는 도구가 다시 검증한다. 확인 절차가 오히려 검증을 건너뛰는 구멍이 되면 안 된다. READ 등급은 확인 경로로 들어올 수 없다(AGENT_UNKNOWN_TOOL).

API

메서드/경로 역할
GET /v1/agent/status 대화창을 열 수 있는지(LLM 키 유무)
POST /v1/agent/chat/{place_id} {message} 또는 {confirm:{tool,args}}

소유자 범위는 다른 엔드포인트와 같다 — 남의 place_id없는 것과 똑같이 PLACE_NOT_FOUND 다. 대화창이 소유자 스코프를 우회하는 유일한 입구가 되면 안 된다.

다음 단계

내용 심사
3 도구를 더 연다 — 사진 내리기 · 섹션 켜고 끄기 · 검색 노출 조회 없음
4 카카오 채널 웹훅을 입구로 추가(서명 검증 + redeem 연결) 채널 + 챗봇

★ 도구를 늘릴 때도 반드시 services/* 를 통과한다. crud 를 직접 부르면 업종 스키마 검증·출처 필수·정정본 보호가 아무 증상 없이 사라진다. collect_service.store_facts 가 크롤러에 걸어 둔 문과 같은 문이고, tests/test_agent_runtime.py 가 소스에서 그 호출이 없는지 실제로 검사한다.


4단계 — 카카오 채널 웹훅

router/v1/agent/kakao_bot.py   시크릿 검증 · 카카오 형식 ↔ 우리 모양   ← 카카오를 아는 유일한 파일
services/agent/channel.py      신원 · 가게 고르기 · 확인 이어받기      ← 카카오를 모른다
services/agent/runtime.py      그대로 — 채널을 모른다

★★ 인증 — 오픈빌더는 서명을 주지 않는다

URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, userRequest.user.id 를 아무 값이나 넣으면 그 사장님 행세를 한다. 신원 연결이 통째로 무의미해지는 자리다.

방법
1 공유 시크릿 — 헤더 X-Agent-Secret (hmac.compare_digest)
2 KAKAO_BOT_ID 대조 (시크릿이 아니라 오발송을 거르는 용도, 비워도 됨)
3 헤더를 못 넣을 때만 경로 시크릿 /webhook/{secret}최후 수단, 경로는 로그에 남는다

KAKAO_WEBHOOK_SECRET 이 비면 엔드포인트가 404 다. 401 로 답하면 "여기 뭔가 있다" 를 알려 준다. 반쯤 열린 상태를 만들지 않는 것은 Threads 연결과 같은 규칙이다.

빌더 화면과 다른 것 셋

빌더 화면 카카오톡
신원 로그인 토큰 연결된 발화자 키 → user_id (★ 토큰을 발급하지 않는다)
대상 place_id 가 URL 에 대화에서 고르고 current_place_id 에 기억
확인 프론트가 {confirm} 을 되돌려 줌 서버가 무엇을 물었는지 들고 있는다

★ 가게가 여럿인데 안 정해졌으면 추측하지 않고 되묻는다. 임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 그 사실을 모른다 — 대화에는 "지금 보고 있는 가게" 가 없다.

pending_expires_at(3분)이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다. 다른 말을 하면 그 말이 우선이고, 묵은 확인은 그 자리에서 치운다.

바로가기 라벨과 '예' 로 읽는 말이 같아야 한다(CONFIRM_LABEL 등 상수). 어긋나면 눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다.

5초 벽

DEADLINE_SEC = 4.0. 넘기면 카카오가 연결을 끊어 말없이 실패하는 봇이 되므로, 안내 문구로 끊는다. 도구 선택은 실측 1.3~2.4초라 여유가 있고, 무거운 잡(BUILD)은 큐에 넣고 즉답하는 구조라 여기 걸리지 않는다. ★ 콜백으로 나중에 미는 길은 아직 없다 — 오픈빌더 지원 여부를 콘솔에서 확인해야 한다.

★ 어떤 실패도 HTTP 200 + 안내 문구로 답한다. 메신저에서는 500 도 침묵으로 보인다.

설정

KAKAO_WEBHOOK_SECRET=     # 비면 웹훅이 404. python -c "import secrets; print(secrets.token_urlsafe(32))"
KAKAO_BOT_ID=             # 선택
KAKAO_CHANNEL_PUBLIC_ID=  # 채워야 연결 카드가 뜬다(채널 검색용 아이디, `_` 로 시작)

오픈빌더에 등록할 주소

https://<발행호스트>/v1/agent/kakao/webhook

★ 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 X-Agent-Secret 을 쓰고, 못 넣으면 /v1/agent/kakao/webhook/<시크릿> 을 쓴다.

채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다. 채팅만 켜면 발화가 우리에게 오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다.