[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>
This commit is contained in:
hbyang 2026-09-22 15:13:33 +09:00
parent 95350cfdbf
commit d32df10cf7
12 changed files with 763 additions and 8 deletions

View File

@ -87,6 +87,13 @@ ALIMTALK_TEMPLATE_CODE=
# ★ 1 이어도 LLM 키가 없으면 안 열린다 — 키 없는 환경에서 켜 둔 채 잊어도
# "눌러도 안 되는 입구" 가 생기지 않는다.
AGENT_CHAT_ENABLED=1
# 카카오톡 채널 웹훅(오픈빌더 스킬 서버). ★ 오픈빌더는 서명을 주지 않는다 —
# URL 만 알면 누구나 때릴 수 있고 발화자 id 를 위조하면 그 사장님 행세를 한다.
# 비우면 웹훅 엔드포인트가 404 다(반쯤 열린 상태를 만들지 않는다).
# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))"
KAKAO_WEBHOOK_SECRET=
# 우리 봇이 맞는지 한 겹 더. 오발송을 거르는 용도라 비워도 된다.
KAKAO_BOT_ID=
KAKAO_CHANNEL_PUBLIC_ID=
KAKAO_LINK_CODE_TTL_MIN=10
KAKAO_LINK_MAX_ATTEMPTS=5

View File

@ -169,6 +169,14 @@
`services/*` 를 통과한다. `collect_service.store_facts` 가 크롤러에 걸어 둔 그 문이다.
- **카카오 채널 발화자는 우리 `user_id` 가 아니다** — 채널 단위 익명 키다.
`owner_kakao_links` 매핑 없이 발화자를 믿으면 **채널 진입점만 소유자 범위 밖**에 놓인다.
- **★ 카카오 웹훅은 서명이 없다 — 시크릿이 유일한 문이다.** 오픈빌더는 서명을 주지 않아서,
URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다.
`KAKAO_WEBHOOK_SECRET` 이 비면 엔드포인트가 **404**(401 은 존재를 알린다).
- **확인 대기에 만료가 없으면 묵은 발행이 돈다** — 카카오톡은 앞선 답을 되돌려 주지 않아
서버가 pending 을 들고 있는다. `pending_expires_at`(3분)을 빼면 한참 뒤의 "네" 한 마디에
실행된다([AGENT.md](docs/AGENT.md)).
- **바로가기 라벨과 '예' 로 읽는 말이 어긋나면 눌러도 안 먹는다** — 사장님은 버튼이 고장난
줄 안다. `channel.py``CONFIRM_LABEL` 상수를 쓰고 문자열을 손으로 적지 않는다.
- **에이전트 대화창은 스위치와 LLM 키를 둘 다 본다**(`AGENT_CHAT_ENABLED`, 기본 `1`).
키만 보면 "잠시 닫아 두기" 가 키를 지우는 일이 되어 소개문·사진분류까지 꺼지고,
스위치만 보면 키 없는 환경에 **눌러도 안 되는 입구**가 생긴다.

View File

@ -4,8 +4,8 @@
**에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도
붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
지금까지 만든 것은 **1단계(신원 연결)****2단계(도구·런타임·빌더 채팅창)** 다.
카카오 채널 웹훅은 아직 없다.
1단계(신원 연결) · 2단계(도구·런타임·빌더 채팅창) · **4단계(카카오 웹훅)** 을 만들었다.
남은 것은 오픈빌더 챗봇 등록(우리가 못 하는 일)과 도구 늘리기다.
## 화면 스위치
@ -79,13 +79,10 @@ RETURNING user_id;
**실패는 전부 같은 에러다**(`KAKAO_LINK_CODE_INVALID`). "없는 코드"·"만료"·"시도 초과" 를
구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다.
## ★ 소비 엔드포인트는 아직 없
## 코드는 웹훅에서만 소비된
코드를 소비하는 쪽은 **채널 웹훅**이고, 그 웹훅은 자체 서명 검증을 갖춘 뒤에야 열 수 있다.
검증 없는 공개 소비 경로를 먼저 만들면 누구나 코드를 대입해 남의 계정에 자기 카톡을 붙인다 —
이 표가 막으려던 바로 그 일이다.
지금 `redeem()` 은 서비스 함수로만 있고 라우터에 붙어 있지 않다.
`redeem()`**공개 라우터에 붙어 있지 않다.** 시크릿 검증을 통과한 웹훅 안에서만 불린다 —
검증 없는 공개 소비 경로가 있으면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다.
## API
@ -181,3 +178,74 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳
검증·출처 필수·정정본 보호가 **아무 증상 없이** 사라진다.
`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/<시크릿>` 을 쓴다.
**채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게
오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다.

View File

@ -1,5 +1,36 @@
# 개발 일지
## 2026-09-22 — 카카오 채널 웹훅(4단계)
카카오톡 채널이 준비돼 웹훅을 만들었다. **런타임은 한 줄도 안 바뀌었다** — 채널을 모르게
만들어 둔 것이 여기서 값을 했다. 새로 생긴 것은 형식 변환(`kakao_bot.py`)과 대화 상태
(`channel.py`)뿐이다.
**★★ 인증 — 오픈빌더는 서명을 주지 않는다**
URL 만 알면 누구나 때릴 수 있고 `userRequest.user.id` 를 위조하면 **그 사장님 행세**를 한다.
1단계에서 만든 신원 연결이 통째로 무의미해지는 자리다. 공유 시크릿(헤더 `X-Agent-Secret`,
`hmac.compare_digest`) + 선택적 `KAKAO_BOT_ID` 대조로 막고, 시크릿이 없으면 **엔드포인트가
404** 다 — 401 은 "여기 뭔가 있다" 를 알려 준다.
**빌더 화면과 다른 것 셋** — 나머지는 `runtime.chat()` 그대로다.
1. 로그인 토큰이 없다 → 발화자 키로 사장님을 찾는다. ★ **토큰을 발급하지 않는다**
(카톡 경로에서 JWT 가 나오면 그게 곧 권한 탈취 경로다)
2. `place_id` 가 URL 에 없다 → 대화에서 고르고 `current_place_id` 에 기억.
★ 여럿인데 안 정해졌으면 **추측하지 않고 되묻는다**
3. 확인을 되돌려 줄 프론트가 없다 → 서버가 pending 을 들고 있는다(0022).
`pending_expires_at`(3분)이 없으면 **한참 뒤의 "네" 한 마디에 묵은 발행이 돈다**
**5초 벽** — `DEADLINE_SEC=4.0`. 넘기면 카카오가 끊어 말없이 실패하는 봇이 되므로 안내로
끊는다. 도구 선택 실측이 1.3~2.4초라 여유가 있다. 콜백은 오픈빌더 지원 여부 확인 뒤에.
어떤 실패도 **200 + 안내 문구**다 — 메신저에서는 500 도 침묵으로 보인다.
**밟은 것** — `DB_SESSION_MNG.execute_lambda`**람다 반환값을 그대로** 준다(CRUD 관례가
`(ErrorType, 값)`). 우리 람다가 객체만 돌려주자 언패킹에서 TypeError 가 났고, 라우터가 모든
예외를 삼키는 구조라 화면에는 "지금은 처리할 수 없어요" 한 줄만 보였다 — 원인이 안 보이는 종류다.
**검증** — `test_kakao_webhook.py` 17 passed(시크릿·위조·만료·가게 되묻기·5초·형식 누출).
전체 `841 passed / 53 failed` 이고 그 53 은 이번 변경 전과 같다.
## 2026-09-22 — 에이전트 대화창 다시 염(기본 켜짐)
카카오톡 채널의 통신사 인증이 끝나 보류를 푼다(사장님 지시). `AGENT_CHAT_ENABLED` 기본값을

View File

@ -636,6 +636,11 @@ CREATE TABLE IF NOT EXISTS public.owner_kakao_links (
status varchar(16) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','LINKED','REVOKED')),
linked_at timestamptz,
last_seen_at timestamptz,
-- 대화 상태(migrations/0022) — 카카오톡은 앞선 답을 되돌려 주지 않는다.
current_place_id uuid,
pending_tool varchar(40),
pending_args jsonb,
pending_expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
deleted boolean NOT NULL DEFAULT false

View File

@ -0,0 +1,14 @@
-- 0022 · owner_kakao_links 에 대화 상태 — 카카오톡은 앞선 답을 되돌려 주지 않는다.
--
-- ★ 빌더 화면은 확인(SEMI) 한 바퀴를 프론트가 이어 줬다. `{confirm:{tool,args}}` 를 그대로
-- 돌려보내므로 서버가 아무것도 기억하지 않아도 됐다.
-- 카카오톡에서 돌아오는 것은 **텍스트 한 줄**뿐이다("네, 해주세요"). 그래서 무엇을 물었는지
-- 서버가 들고 있어야 한다.
--
-- ★ pending_expires_at 이 없으면 조용히 틀린다: 사장님이 한참 뒤 다른 맥락에서 "네" 라고
-- 치는 순간 **묵은 발행이 실행된다.** 그 사이에 값이 더 바뀌었을 수도 있다.
ALTER TABLE public.owner_kakao_links
ADD COLUMN IF NOT EXISTS current_place_id uuid,
ADD COLUMN IF NOT EXISTS pending_tool varchar(40),
ADD COLUMN IF NOT EXISTS pending_args jsonb,
ADD COLUMN IF NOT EXISTS pending_expires_at timestamptz;

View File

@ -721,6 +721,12 @@ class owner_kakao_links(MainTableMixin, MAIN_BASE):
status = Column(String(16), nullable=False, server_default=text("'PENDING'"))
linked_at = Column(DateTime(timezone=True), nullable=True)
last_seen_at = Column(DateTime(timezone=True), nullable=True)
# 대화 상태 — 카카오톡은 앞선 답을 되돌려 주지 않는다(빌더 화면은 프론트가 이어 줬다).
# ★ pending_expires_at 이 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
current_place_id = Column(UUID(as_uuid=True), nullable=True)
pending_tool = Column(String(40), nullable=True)
pending_args = Column(JSONB, nullable=True)
pending_expires_at = Column(DateTime(timezone=True), nullable=True)
__table_args__ = (
Index("uq_kakao_link_user", "user_id", unique=True, postgresql_where=text("deleted=false AND status IN ('PENDING','LINKED')")),
Index("uq_kakao_link_channel_key", "channel_user_key", unique=True, postgresql_where=text("deleted=false AND status='LINKED'")),

View File

@ -30,6 +30,16 @@ class AgentConfig(BaseSettings):
# 다시 닫을 일이 생기면 이 값만 "0" 으로 되돌린다. 코드를 되짚지 않는다.
AGENT_CHAT_ENABLED: str = "1"
# ★ 카카오 웹훅 인증. **오픈빌더는 서명을 주지 않는다** — URL 만 알면 누구나 이 엔드포인트를
# 때릴 수 있고, user.id 를 아무 값이나 넣으면 **그 사장님 행세를 한다.** 신원 연결
# (owner_kakao_links)이 통째로 무의미해진다.
# 그래서 이 값이 없으면 **엔드포인트 자체를 띄우지 않는다**(404). 반쯤 열린 상태를
# 만들지 않는 것은 Threads 연결과 같은 규칙이다.
# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))"
KAKAO_WEBHOOK_SECRET: str = ""
# 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다.
KAKAO_BOT_ID: str = ""
def get(name, default=""):
return getattr(AgentConfig(), name, default) or default
@ -39,6 +49,10 @@ def chat_enabled() -> bool:
return get("AGENT_CHAT_ENABLED", "0") == "1"
def webhook_secret() -> str:
return get("KAKAO_WEBHOOK_SECRET")
def kakao_link_enabled() -> bool:
return bool(get("KAKAO_CHANNEL_PUBLIC_ID"))

View File

@ -29,6 +29,7 @@ import router.v1.social.social
import router.v1.social.oauth
import router.v1.agent.kakao
import router.v1.agent.chat
import router.v1.agent.kakao_bot
API_SERVER_START_TIME = GTime.UTCStr()
@ -142,3 +143,4 @@ app.include_router(router.v1.social.social.router)
app.include_router(router.v1.social.oauth.router)
app.include_router(router.v1.agent.kakao.router)
app.include_router(router.v1.agent.chat.router)
app.include_router(router.v1.agent.kakao_bot.router)

View File

@ -0,0 +1,101 @@
"""카카오톡 채널 웹훅(오픈빌더 스킬 서버) — 카카오 형식은 **이 파일 밖으로 나가지 않는다**.
`version: "2.0"` · `simpleText` · `quickReplies` 같은 모양이 서비스 계층에 새면, 다른 채널을
붙일 그걸 전부 걷어내야 한다. 알림톡 어댑터에 것과 같은 규칙이다.
**오픈빌더는 서명을 주지 않는다.** URL 알면 누구나 엔드포인트를 때릴 있고,
`userRequest.user.id` 아무 값이나 넣으면 ** 사장님 행세를 한다** 신원 연결
(`owner_kakao_links`) 통째로 무의미해진다. 그래서 공유 시크릿을 우리가 직접 댄다.
시크릿이 없으면 **엔드포인트 자체를 띄우지 않는다(404)** 반쯤 열린 상태를 만들지 않는 것은
Threads 연결과 같은 규칙이다.
5 : 오픈빌더는 스킬 서버 응답을 오래 기다리지 않는다. 넘기면 카카오가 연결을 끊고,
사장님에게는 **말없이 실패하는 ** 된다. 무거운 (BUILD) 이미 큐에 넣고 즉답하는
구조라 여기 걸리지 않지만, 상한은 명시해 둔다.
"""
import asyncio
import hmac
from fastapi import APIRouter, Header, HTTPException, Request
from common.logger import LOG
from config import agent_config as config
from services.agent import channel
router = APIRouter(prefix="/v1/agent/kakao", tags=["Agent"])
# 도구 선택 1콜이 실측 1.3~2.4초다. 4초를 넘기면 답을 포기하고 안내 문구로 끊는다 —
# 침묵보다 "잠시 뒤 다시" 가 낫다.
DEADLINE_SEC = 4.0
_TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요."
_ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요."
def _reply(text: str, quick_replies=None) -> dict:
"""오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다."""
payload: dict = {"outputs": [{"simpleText": {"text": text}}]}
if quick_replies:
# 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** —
# channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다.
payload["quickReplies"] = [
{"label": label, "action": "message", "messageText": label} for label in quick_replies[:10]
]
return {"version": "2.0", "template": payload}
def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict) -> None:
expected = config.webhook_secret()
if not expected:
# 설정이 없으면 이 기능은 존재하지 않는다. 401 로 답하면 엔드포인트의 존재를 알린다.
raise HTTPException(404)
given = header_secret or secret_in_path or ""
if not hmac.compare_digest(given, expected):
LOG.w("[agent/kakao] 웹훅 시크릿 불일치 — 거절")
raise HTTPException(404)
# 한 겹 더. 시크릿이 아니라 오발송을 거르는 용도라 비워 두면 검사하지 않는다.
bot_id = config.get("KAKAO_BOT_ID")
if bot_id and (body.get("bot") or {}).get("id") != bot_id:
LOG.w("[agent/kakao] 다른 봇의 요청 — 거절")
raise HTTPException(404)
async def _handle(body: dict) -> dict:
request = body.get("userRequest") or {}
utterance = request.get("utterance") or ""
speaker = (request.get("user") or {}).get("id") or ""
if not speaker:
# 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다.
return _reply("사용자를 확인하지 못했어요.")
try:
answer = await asyncio.wait_for(channel.handle(utterance, speaker), timeout=DEADLINE_SEC)
except asyncio.TimeoutError:
# ★ 콜백으로 나중에 미는 길은 아직 없다(오픈빌더 지원 여부 확인 필요, docs/AGENT.md).
LOG.w("[agent/kakao] 응답 시간 초과 — 안내로 끊음")
return _reply(_TIMEOUT_TEXT)
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
return _reply(_ERROR_TEXT)
return _reply(answer["text"], answer.get("quick_replies"))
@router.post("/webhook")
async def webhook(request: Request, x_agent_secret: str | None = Header(default=None)):
"""헤더로 시크릿을 받는 쪽. 스킬 설정에서 커스텀 헤더를 넣을 수 있으면 이쪽을 쓴다."""
body = await request.json()
_authorize(None, x_agent_secret, body)
return await _handle(body)
@router.post("/webhook/{secret}")
async def webhook_with_path_secret(secret: str, request: Request, x_agent_secret: str | None = Header(default=None)):
"""헤더를 못 넣는 경우의 대안.
최후 수단이다 경로는 액세스 로그·앞단 프록시에 남는다. 헤더를 있으면 위를 쓴다."""
body = await request.json()
_authorize(secret, x_agent_secret, body)
return await _handle(body)

View File

@ -0,0 +1,235 @@
"""메신저 대화 한 턴 — 신원 · 가게 고르기 · 확인 이어받기.
**카카오를 모른다.** `version: "2.0"` · `simpleText` 같은 형식은 글자도 여기 없다.
그건 `router/v1/agent/kakao_bot.py` 안에서 끝난다 새면 다른 채널을 붙일 전부
걷어내야 하고, 알림톡 어댑터에 것과 같은 규칙이다.
빌더 화면과 무엇이 다른가 셋뿐이다.
1. 로그인 토큰이 없다 연결된 발화자 키로 사장님을 찾는다
2. place_id URL 없다 대화에서 고르고 기억한다
3. 확인을 되돌려 프론트가 없다 무엇을 물었는지 서버가 들고 있는다
나머지(도구·등급·게이트) `runtime.chat()` 그대로다.
"""
import re
import uuid
from datetime import datetime, timedelta, timezone
from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import owner_kakao_links as Link
from common.database.model.models import users
from common.enums import DBWRType, ErrorType, KakaoLinkStatus
from common.models.gmodel import UserInfo
from crud.place_crud import PlaceCRUD
from services import kakao_link_service as link_service
from services.agent import runtime
from services.agent.tools import REGISTRY
from services.kakao_link_service import KakaoLinkError
# 연결 코드 모양(kakao_link_service._CODE_ALPHABET 과 같은 글자 집합).
CODE_PATTERN = re.compile(r"[ABCDEFGHJKMNPQRSTUVWXYZ23456789]{6}")
# 확인 대기 수명. ★ 이게 없으면 한참 뒤의 "네" 한 마디에 묵은 발행이 실행된다.
PENDING_MINUTES = 3
# ★ 바로가기 라벨과 '예' 로 읽는 말이 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이
# 고장난 줄 안다. 라벨을 상수로 두고 _YES 가 그것을 포함하게 묶는다.
CONFIRM_LABEL = "네, 해주세요"
PUBLISH_LABEL = "네, 발행해주세요"
DECLINE_LABEL = "아니요"
_YES = {CONFIRM_LABEL, PUBLISH_LABEL, "", "", "", "그래", "네 해주세요", "해주세요", "좋아", "ㅇㅇ", "확인"}
_NO = {DECLINE_LABEL, "아니", "아니오", "안할래", "취소", "나중에", "ㄴㄴ"}
def _now():
return datetime.now(timezone.utc)
def _say(text: str, quick: list[str] | None = None) -> dict:
"""채널이 모르는 모양으로 답한다 — 문구와 바로가기 목록뿐이다."""
return {"text": text, "quick_replies": quick or []}
async def _user_info(user_id) -> UserInfo | None:
"""user_id → UserInfo. ★ 토큰을 발급하지 않는다.
프로세스 안에서 객체만 만든다 카톡 경로에서 JWT 나오면 그게 권한 탈취
경로다(docs/AGENT.md)."""
async def run(s):
row = (await s.execute(select(users).where(users.user_id == user_id, users.deleted.is_(False)))).scalars().first()
return ErrorType.SUCCESS, row
# ★ execute_lambda 는 람다 반환값을 **그대로** 준다. CRUD 관례(ErrorType, 값)를 따라
# 우리 람다도 같은 모양으로 돌려준다 — 안 맞추면 여기서 TypeError 로 조용히 죽는다.
err, row = await DB_SESSION_MNG.execute_lambda(users.DBType(), DBWRType.DB_READ.value, run)
if err != ErrorType.SUCCESS or row is None:
return None
return UserInfo(user_id=str(row.user_id), id=row.id, role=row.role, token_version=row.token_version)
async def _link_row(channel_user_key: str):
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()
return ErrorType.SUCCESS, row
_err, row = await DB_SESSION_MNG.execute_lambda(Link.DBType(), DBWRType.DB_READ.value, run)
return row
async def _update_link(channel_user_key: str, **values):
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
for name, value in values.items():
setattr(row, name, value)
return row
await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
async def _clear_pending(key):
await _update_link(key, pending_tool=None, pending_args=None, pending_expires_at=None)
async def _places(user: UserInfo) -> list:
# ★ (err, rows, total) 셋으로 풀린다 — place_service.list_places 와 같은 호출 모양이다.
err, rows, _total = await DB_SESSION_MNG.execute_lambda(
Link.DBType(),
DBWRType.DB_READ.value,
lambda s: PlaceCRUD().list_places(s, uuid.UUID(user.user_id), None, None, None, 0, 20),
)
return list(rows or []) if err == ErrorType.SUCCESS else []
async def _pick_place(user: UserInfo, row, utterance: str):
"""어느 가게 이야기인지 정한다.
여럿인데 정해졌으면 **되묻는다.** 임의로 가게를 고르면, 사장님은 엉뚱한 가게를
고쳐 놓고도 사실을 모른다 화면과 달리 대화에는 "지금 보고 있는 가게" 없다.
반환: (place_id, 되물을 or None)"""
places = await _places(user)
if not places:
return None, _say("아직 등록된 가게가 없어요. 홈페이지를 먼저 만들어 주세요.")
names = {p.name.strip(): p for p in places}
# 바로가기를 눌렀거나 가게 이름을 그대로 말한 경우 — 그 가게로 맞춘다.
chosen = names.get(utterance.strip())
if chosen is not None:
await _update_link(row.channel_user_key, current_place_id=chosen.place_id,
pending_tool=None, pending_args=None, pending_expires_at=None)
return None, _say(f"'{chosen.name}' 으로 맞췄습니다. 무엇을 도와드릴까요?")
if len(places) == 1:
if row.current_place_id != places[0].place_id:
await _update_link(row.channel_user_key, current_place_id=places[0].place_id)
return str(places[0].place_id), None
if row.current_place_id is not None:
return str(row.current_place_id), None
return None, _say("어느 가게 이야기일까요?", [p.name for p in places[:10]])
async def handle(utterance: str, channel_user_key: str) -> dict:
"""대화 한 턴. 예외를 던지지 않는다 — 메신저에서는 500 도 침묵으로 보인다."""
utterance = (utterance or "").strip()
if not utterance:
return _say("무엇을 도와드릴까요?")
row = await _link_row(channel_user_key)
# ── 아직 연결되지 않은 발화자 ─────────────────────────────────────────
if row is None:
found = CODE_PATTERN.fullmatch(utterance.upper())
if not found:
return _say("먼저 홈페이지 관리자 화면의 [내 사이트]에서 카카오톡 연결 코드를 받아 보내 주세요.")
try:
await link_service.redeem(utterance, channel_user_key)
except KakaoLinkError:
# ★ 없는 코드·만료·시도 초과를 구분해 답하지 않는다(kakao_link_service 주석).
return _say("코드가 맞지 않거나 시간이 지났어요. 새 코드를 받아 다시 보내 주세요.")
return _say("연결됐습니다. 이제 여기서 홈페이지를 고칠 수 있어요.\n예) 체크인 시간 3시로 바꿔줘")
user = await _user_info(row.user_id)
if user is None:
return _say("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.")
# ── 확인 이어받기 ────────────────────────────────────────────────────
pending = None
if row.pending_tool and row.pending_expires_at and row.pending_expires_at > _now():
pending = {"tool": row.pending_tool, "args": row.pending_args or {}}
elif row.pending_tool:
# 만료. 조용히 흘리지 않고 치운다 — 남아 있으면 다음 "네" 가 그걸 집는다.
await _clear_pending(channel_user_key)
if pending is not None:
if utterance in _YES:
await _clear_pending(channel_user_key)
result = await runtime.chat(user, str(row.current_place_id), "", confirm=pending)
return _say(result["reply"])
if utterance in _NO:
await _clear_pending(channel_user_key)
return _say("알겠습니다. 그대로 두겠습니다.")
# 다른 말을 했으면 그 말이 우선이다. 묵은 확인을 들고 있지 않는다.
await _clear_pending(channel_user_key)
# ── 가게 고르기 ──────────────────────────────────────────────────────
place_id, ask = await _pick_place(user, row, utterance)
if ask is not None:
return ask
# ── 도구 ─────────────────────────────────────────────────────────────
try:
result = await runtime.chat(user, place_id, utterance)
except runtime.AgentError as ex:
return _say(_ERRORS.get(str(ex), "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요."))
if result.get("needs_confirm") and result.get("tool"):
await _update_link(
channel_user_key,
pending_tool=result["tool"],
pending_args=result.get("args") or {},
pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES),
)
return _say(result["reply"], [CONFIRM_LABEL, DECLINE_LABEL])
# 값을 고쳤으면 재발행을 바로 누를 수 있게 바로가기를 붙인다 — 도구가 이미 그렇게 묻는다.
quick = [PUBLISH_LABEL, DECLINE_LABEL] if result.get("done") and result.get("tool") != REGISTRY["publish"].name else []
if quick:
await _update_link(
channel_user_key,
pending_tool="publish",
pending_args={},
pending_expires_at=_now() + timedelta(minutes=PENDING_MINUTES),
)
return _say(result["reply"], quick)
_ERRORS = {
"PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.",
"AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.",
"AGENT_MESSAGE_TOO_LONG": "말씀이 조금 길어요. 짧게 나눠서 말씀해 주세요.",
"AGENT_CALL_FAILED": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
}

View File

@ -0,0 +1,264 @@
"""카카오톡 채널 웹훅.
여기서 지키는 :
1. 시크릿 없는 요청은 아무것도 하지 못한다 오픈빌더가 서명을 주지 않으므로 이게 유일한 문이다
2. 연결되지 않은 발화자는 어떤 사장님도 되지 못한다
3. 확인(SEMI) **만료되면 먹는다** 한참 뒤의 "" 마디에 묵은 발행이 돌면 된다
"""
import uuid
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from sqlalchemy import text
from services import kakao_link_service as link_service
from services.agent import channel, runtime
SECRET = "test-webhook-secret-0123456789"
PATH = "/v1/agent/kakao/webhook"
@pytest.fixture(autouse=True)
def secret(monkeypatch):
monkeypatch.setenv("KAKAO_WEBHOOK_SECRET", SECRET)
monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh")
monkeypatch.setenv("AGENT_CHAT_ENABLED", "1")
monkeypatch.delenv("KAKAO_BOT_ID", raising=False)
def body(utterance, speaker="kakao-speaker-1", bot_id="bot-1"):
return {
"userRequest": {"utterance": utterance, "user": {"id": speaker, "type": "botUserKey"}},
"bot": {"id": bot_id, "name": "ADO2"},
}
def said(res) -> str:
return res.json()["template"]["outputs"][0]["simpleText"]["text"]
def quick(res) -> list:
return [q["label"] for q in res.json()["template"].get("quickReplies", [])]
async def owner_with_place(client, auth_headers, name="대화숙소"):
h = await auth_headers(f"kakao-{uuid.uuid4().hex[:8]}")
res = await client.post("/v1/place", headers=h, json={"name": name, "category": 1})
return h, res.json()["place"]["place_id"]
async def user_id_of(db_engine, place_id):
async with db_engine.begin() as c:
return (
await c.execute(text("SELECT owner_user_id FROM places WHERE place_id=:p"), {"p": uuid.UUID(place_id)})
).scalar_one()
async def link(db_engine, client, auth_headers, speaker, name="대화숙소"):
"""사장님 하나 + 가게 하나 + 그 사장님에 묶인 카톡 발화자."""
h, pid = await owner_with_place(client, auth_headers, name)
uid = await user_id_of(db_engine, pid)
await link_service.redeem((await link_service.issue_code(uid))["code"], speaker)
return h, pid, uid
# ── 1. 시크릿이 유일한 문이다 ────────────────────────────────────────────
async def test_시크릿이_없으면_존재를_알리지_않는다(client, monkeypatch):
"""★ 401 이 아니라 404 다. 401 은 '여기 뭔가 있다' 를 알려 준다."""
monkeypatch.setenv("KAKAO_WEBHOOK_SECRET", "")
assert (await client.post(PATH, json=body("안녕"))).status_code == 404
async def test_틀린_시크릿도_404(client):
res = await client.post(PATH, headers={"X-Agent-Secret": "wrong-secret"}, json=body("안녕"))
assert res.status_code == 404
res = await client.post(PATH, json=body("안녕")) # 헤더 없음
assert res.status_code == 404
async def test_경로_시크릿도_받는다(client, db_engine):
res = await client.post(f"{PATH}/{SECRET}", json=body("안녕"))
assert res.status_code == 200
res = await client.post(f"{PATH}/wrong-secret", json=body("안녕"))
assert res.status_code == 404
async def test_다른_봇의_요청은_거절한다(client, monkeypatch, db_engine):
monkeypatch.setenv("KAKAO_BOT_ID", "bot-1")
ok = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", bot_id="bot-1"))
assert ok.status_code == 200
bad = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕", bot_id="남의봇"))
assert bad.status_code == 404
# ── 2. 연결되지 않은 발화자 ──────────────────────────────────────────────
async def test_연결_전에는_어떤_도구도_돌지_않는다(client, monkeypatch, db_engine):
called = AsyncMock()
monkeypatch.setattr(runtime, "chat", called)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("체크인 3시로 바꿔줘", "모르는-키"))
assert res.status_code == 200
assert "연결" in said(res)
called.assert_not_awaited()
async def test_발화자_id_를_위조해도_남의_가게에_닿지_않는다(client, auth_headers, db_engine, monkeypatch):
"""★ 신원 연결이 없으면 카톡 진입점만 소유자 범위 밖에 놓인다 — 그걸 막는 자리다."""
await link(db_engine, client, auth_headers, "진짜-사장님-키")
called = AsyncMock()
monkeypatch.setattr(runtime, "chat", called)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("정보 보여줘", "위조한-키"))
assert "연결" in said(res)
called.assert_not_awaited()
async def test_코드를_보내면_연결된다(client, auth_headers, db_engine):
h, pid = await owner_with_place(client, auth_headers)
uid = await user_id_of(db_engine, pid)
code = (await link_service.issue_code(uid))["code"]
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body(code, "새-발화자"))
assert "연결됐습니다" in said(res)
assert await link_service.resolve("새-발화자") == uid
async def test_틀린_코드는_이유를_구분해_말하지_않는다(client, db_engine):
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("ZZZZZZ", "새-발화자2"))
assert "코드가 맞지 않거나" in said(res)
# ── 3. 확인은 만료되면 안 먹는다 ─────────────────────────────────────────
async def test_발행은_묻고_바로가기를_준다(client, auth_headers, db_engine, monkeypatch):
speaker = "확인-테스트-키"
await link(db_engine, client, auth_headers, speaker)
# 테스트는 실제 모델을 부르지 않는다 — 런타임만 열고 선택 결과를 대신 준다.
monkeypatch.setattr(runtime, "is_configured", lambda: True)
monkeypatch.setattr(runtime, "_choose", AsyncMock(return_value={"tool": "publish", "args": {}, "message": ""}))
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("발행해줘", speaker))
assert channel.CONFIRM_LABEL in quick(res)
async with db_engine.begin() as c:
pending = (
await c.execute(text("SELECT pending_tool FROM owner_kakao_links WHERE channel_user_key=:k"), {"k": speaker})
).scalar_one()
assert pending == "publish"
async def test_만료된_확인에_네_라고_해도_실행되지_않는다(client, auth_headers, db_engine, monkeypatch):
"""★ 이게 없으면 한참 뒤의 '' 한 마디에 **묵은 발행**이 돈다."""
speaker = "만료-테스트-키"
await link(db_engine, client, auth_headers, speaker)
async with db_engine.begin() as c:
await c.execute(
text("""UPDATE owner_kakao_links
SET pending_tool='publish', pending_args='{}'::jsonb,
pending_expires_at = now() - interval '1 minute'
WHERE channel_user_key=:k"""),
{"k": speaker},
)
ran = AsyncMock()
monkeypatch.setattr(runtime, "chat", ran)
await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("", speaker))
# 확인으로 실행된 적이 없다(다른 말로 취급돼 일반 경로로 갔을 수는 있다).
for call in ran.await_args_list:
assert call.kwargs.get("confirm") is None
async def test_바로가기_라벨은_예로_읽히는_말에_들어_있다():
"""★ 라벨과 _YES 가 어긋나면 **눌러도 안 먹는다** — 사장님은 버튼이 고장난 줄 안다."""
assert channel.CONFIRM_LABEL in channel._YES
assert channel.PUBLISH_LABEL in channel._YES
assert channel.DECLINE_LABEL in channel._NO
# ── 가게 고르기 ──────────────────────────────────────────────────────────
async def test_가게가_여럿이면_추측하지_않고_되묻는다(client, auth_headers, db_engine, monkeypatch):
"""★ 임의로 첫 가게를 고르면 사장님은 엉뚱한 가게를 고쳐 놓고도 모른다."""
speaker = "다가게-키"
h, _pid, _uid = await link(db_engine, client, auth_headers, speaker, "첫째가게")
await client.post("/v1/place", headers=h, json={"name": "둘째가게", "category": 1})
called = AsyncMock()
monkeypatch.setattr(runtime, "chat", called)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("정보 보여줘", speaker))
assert "어느 가게" in said(res)
assert set(quick(res)) == {"첫째가게", "둘째가게"}
called.assert_not_awaited()
# 고르면 기억한다.
picked = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("둘째가게", speaker))
assert "둘째가게" in said(picked)
async with db_engine.begin() as c:
current = (
await c.execute(
text("SELECT current_place_id FROM owner_kakao_links WHERE channel_user_key=:k"), {"k": speaker}
)
).scalar_one()
assert current is not None
# ── 5초 벽 · 실패 ────────────────────────────────────────────────────────
async def test_느리면_침묵_대신_안내로_끊는다(client, monkeypatch, db_engine):
"""넘기면 카카오가 연결을 끊는다 — 사장님에게는 말없이 실패하는 봇이 된다."""
import router.v1.agent.kakao_bot as bot
async def slow(*_a, **_kw):
import asyncio
await asyncio.sleep(1)
monkeypatch.setattr(bot, "DEADLINE_SEC", 0.01)
monkeypatch.setattr(bot.channel, "handle", slow)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕"))
assert res.status_code == 200
assert "잠시 뒤" in said(res)
async def test_내부_오류도_200_으로_답한다(client, monkeypatch, db_engine):
"""메신저에서는 500 도 침묵으로 보인다 — 무슨 일이 있었는지 한 줄은 말해야 한다."""
import router.v1.agent.kakao_bot as bot
monkeypatch.setattr(bot.channel, "handle", AsyncMock(side_effect=RuntimeError("어딘가 터짐")))
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("안녕"))
assert res.status_code == 200
assert "어딘가 터짐" not in res.text
async def test_발화자가_없으면_거기서_끝낸다(client, db_engine):
res = await client.post(
PATH, headers={"X-Agent-Secret": SECRET}, json={"userRequest": {"utterance": "안녕", "user": {}}}
)
assert res.status_code == 200
assert "확인하지 못했" in said(res)
# ── 응답 형식 ────────────────────────────────────────────────────────────
def test_카카오_형식은_이_파일_밖으로_나가지_않는다():
"""★ 서비스 계층에 새면 다른 채널을 붙일 때 전부 걷어내야 한다."""
import ast
import inspect
# ★ 주석·docstring 에 이름이 나오는 것은 '샌' 것이 아니다 — 실제 코드만 본다.
tree = ast.parse(inspect.getsource(channel))
for node in ast.walk(tree):
if isinstance(node, ast.Expr) and isinstance(node.value, ast.Constant) and isinstance(node.value.value, str):
node.value.value = "" # docstring 비우기
dumped = ast.dump(tree)
for token in ("simpleText", "quickReplies", "userRequest", "2.0"):
assert token not in dumped, token
def test_바로가기는_열_개를_넘기지_않는다():
import router.v1.agent.kakao_bot as bot
out = bot._reply("안녕", [f"라벨{i}" for i in range(20)])
assert len(out["template"]["quickReplies"]) == 10
assert out["version"] == "2.0"
assert out["template"]["outputs"][0]["simpleText"]["text"] == "안녕"