[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:
parent
95350cfdbf
commit
d32df10cf7
@ -87,6 +87,13 @@ ALIMTALK_TEMPLATE_CODE=
|
|||||||
# ★ 1 이어도 LLM 키가 없으면 안 열린다 — 키 없는 환경에서 켜 둔 채 잊어도
|
# ★ 1 이어도 LLM 키가 없으면 안 열린다 — 키 없는 환경에서 켜 둔 채 잊어도
|
||||||
# "눌러도 안 되는 입구" 가 생기지 않는다.
|
# "눌러도 안 되는 입구" 가 생기지 않는다.
|
||||||
AGENT_CHAT_ENABLED=1
|
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_CHANNEL_PUBLIC_ID=
|
||||||
KAKAO_LINK_CODE_TTL_MIN=10
|
KAKAO_LINK_CODE_TTL_MIN=10
|
||||||
KAKAO_LINK_MAX_ATTEMPTS=5
|
KAKAO_LINK_MAX_ATTEMPTS=5
|
||||||
|
|||||||
@ -169,6 +169,14 @@
|
|||||||
`services/*` 를 통과한다. `collect_service.store_facts` 가 크롤러에 걸어 둔 그 문이다.
|
`services/*` 를 통과한다. `collect_service.store_facts` 가 크롤러에 걸어 둔 그 문이다.
|
||||||
- **카카오 채널 발화자는 우리 `user_id` 가 아니다** — 채널 단위 익명 키다.
|
- **카카오 채널 발화자는 우리 `user_id` 가 아니다** — 채널 단위 익명 키다.
|
||||||
`owner_kakao_links` 매핑 없이 발화자를 믿으면 **채널 진입점만 소유자 범위 밖**에 놓인다.
|
`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`).
|
- **에이전트 대화창은 스위치와 LLM 키를 둘 다 본다**(`AGENT_CHAT_ENABLED`, 기본 `1`).
|
||||||
키만 보면 "잠시 닫아 두기" 가 키를 지우는 일이 되어 소개문·사진분류까지 꺼지고,
|
키만 보면 "잠시 닫아 두기" 가 키를 지우는 일이 되어 소개문·사진분류까지 꺼지고,
|
||||||
스위치만 보면 키 없는 환경에 **눌러도 안 되는 입구**가 생긴다.
|
스위치만 보면 키 없는 환경에 **눌러도 안 되는 입구**가 생긴다.
|
||||||
|
|||||||
@ -4,8 +4,8 @@
|
|||||||
**에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도
|
**에이전트는 카카오톡 안에 있지 않다.** 카톡은 입구 하나이고, 같은 에이전트가 빌더 화면에도
|
||||||
붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
|
붙는다. 그래야 채널·챗봇 심사 전에 전부 검증된다.
|
||||||
|
|
||||||
지금까지 만든 것은 **1단계(신원 연결)** 와 **2단계(도구·런타임·빌더 채팅창)** 다.
|
1단계(신원 연결) · 2단계(도구·런타임·빌더 채팅창) · **4단계(카카오 웹훅)** 을 만들었다.
|
||||||
카카오 채널 웹훅은 아직 없다.
|
남은 것은 오픈빌더 챗봇 등록(우리가 못 하는 일)과 도구 늘리기다.
|
||||||
|
|
||||||
## 화면 스위치
|
## 화면 스위치
|
||||||
|
|
||||||
@ -79,13 +79,10 @@ RETURNING user_id;
|
|||||||
**실패는 전부 같은 에러다**(`KAKAO_LINK_CODE_INVALID`). "없는 코드"·"만료"·"시도 초과" 를
|
**실패는 전부 같은 에러다**(`KAKAO_LINK_CODE_INVALID`). "없는 코드"·"만료"·"시도 초과" 를
|
||||||
구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다.
|
구분해 답하면 6자리 코드의 유효성을 외부에서 탐색할 수 있다.
|
||||||
|
|
||||||
## ★ 소비 엔드포인트는 아직 없다
|
## 코드는 웹훅에서만 소비된다
|
||||||
|
|
||||||
코드를 소비하는 쪽은 **채널 웹훅**이고, 그 웹훅은 자체 서명 검증을 갖춘 뒤에야 열 수 있다.
|
`redeem()` 은 **공개 라우터에 붙어 있지 않다.** 시크릿 검증을 통과한 웹훅 안에서만 불린다 —
|
||||||
검증 없는 공개 소비 경로를 먼저 만들면 누구나 코드를 대입해 남의 계정에 자기 카톡을 붙인다 —
|
검증 없는 공개 소비 경로가 있으면 누구나 6자리를 대입해 남의 계정에 자기 카톡을 붙인다.
|
||||||
이 표가 막으려던 바로 그 일이다.
|
|
||||||
|
|
||||||
지금 `redeem()` 은 서비스 함수로만 있고 라우터에 붙어 있지 않다.
|
|
||||||
|
|
||||||
## API
|
## API
|
||||||
|
|
||||||
@ -181,3 +178,74 @@ services/fact_service.py · site_service.py ★ 게이트가 사는 곳
|
|||||||
검증·출처 필수·정정본 보호가 **아무 증상 없이** 사라진다.
|
검증·출처 필수·정정본 보호가 **아무 증상 없이** 사라진다.
|
||||||
`collect_service.store_facts` 가 크롤러에 걸어 둔 문과 같은 문이고,
|
`collect_service.store_facts` 가 크롤러에 걸어 둔 문과 같은 문이고,
|
||||||
`tests/test_agent_runtime.py` 가 소스에서 그 호출이 없는지 실제로 검사한다.
|
`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/<시크릿>` 을 쓴다.
|
||||||
|
|
||||||
|
★ **채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게
|
||||||
|
오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다.
|
||||||
|
|||||||
@ -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 — 에이전트 대화창 다시 염(기본 켜짐)
|
## 2026-09-22 — 에이전트 대화창 다시 염(기본 켜짐)
|
||||||
|
|
||||||
카카오톡 채널의 통신사 인증이 끝나 보류를 푼다(사장님 지시). `AGENT_CHAT_ENABLED` 기본값을
|
카카오톡 채널의 통신사 인증이 끝나 보류를 푼다(사장님 지시). `AGENT_CHAT_ENABLED` 기본값을
|
||||||
|
|||||||
@ -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')),
|
status varchar(16) NOT NULL DEFAULT 'PENDING' CHECK (status IN ('PENDING','LINKED','REVOKED')),
|
||||||
linked_at timestamptz,
|
linked_at timestamptz,
|
||||||
last_seen_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(),
|
created_at timestamptz NOT NULL DEFAULT now(),
|
||||||
updated_at timestamptz NOT NULL DEFAULT now(),
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
deleted boolean NOT NULL DEFAULT false
|
deleted boolean NOT NULL DEFAULT false
|
||||||
|
|||||||
@ -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;
|
||||||
@ -721,6 +721,12 @@ class owner_kakao_links(MainTableMixin, MAIN_BASE):
|
|||||||
status = Column(String(16), nullable=False, server_default=text("'PENDING'"))
|
status = Column(String(16), nullable=False, server_default=text("'PENDING'"))
|
||||||
linked_at = Column(DateTime(timezone=True), nullable=True)
|
linked_at = Column(DateTime(timezone=True), nullable=True)
|
||||||
last_seen_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__ = (
|
__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_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'")),
|
Index("uq_kakao_link_channel_key", "channel_user_key", unique=True, postgresql_where=text("deleted=false AND status='LINKED'")),
|
||||||
|
|||||||
@ -30,6 +30,16 @@ class AgentConfig(BaseSettings):
|
|||||||
# 다시 닫을 일이 생기면 이 값만 "0" 으로 되돌린다. 코드를 되짚지 않는다.
|
# 다시 닫을 일이 생기면 이 값만 "0" 으로 되돌린다. 코드를 되짚지 않는다.
|
||||||
AGENT_CHAT_ENABLED: str = "1"
|
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=""):
|
def get(name, default=""):
|
||||||
return getattr(AgentConfig(), name, default) or default
|
return getattr(AgentConfig(), name, default) or default
|
||||||
@ -39,6 +49,10 @@ def chat_enabled() -> bool:
|
|||||||
return get("AGENT_CHAT_ENABLED", "0") == "1"
|
return get("AGENT_CHAT_ENABLED", "0") == "1"
|
||||||
|
|
||||||
|
|
||||||
|
def webhook_secret() -> str:
|
||||||
|
return get("KAKAO_WEBHOOK_SECRET")
|
||||||
|
|
||||||
|
|
||||||
def kakao_link_enabled() -> bool:
|
def kakao_link_enabled() -> bool:
|
||||||
return bool(get("KAKAO_CHANNEL_PUBLIC_ID"))
|
return bool(get("KAKAO_CHANNEL_PUBLIC_ID"))
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,7 @@ import router.v1.social.social
|
|||||||
import router.v1.social.oauth
|
import router.v1.social.oauth
|
||||||
import router.v1.agent.kakao
|
import router.v1.agent.kakao
|
||||||
import router.v1.agent.chat
|
import router.v1.agent.chat
|
||||||
|
import router.v1.agent.kakao_bot
|
||||||
|
|
||||||
API_SERVER_START_TIME = GTime.UTCStr()
|
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.social.oauth.router)
|
||||||
app.include_router(router.v1.agent.kakao.router)
|
app.include_router(router.v1.agent.kakao.router)
|
||||||
app.include_router(router.v1.agent.chat.router)
|
app.include_router(router.v1.agent.chat.router)
|
||||||
|
app.include_router(router.v1.agent.kakao_bot.router)
|
||||||
|
|||||||
101
solution/backend/router/v1/agent/kakao_bot.py
Normal file
101
solution/backend/router/v1/agent/kakao_bot.py
Normal 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)
|
||||||
235
solution/backend/services/agent/channel.py
Normal file
235
solution/backend/services/agent/channel.py
Normal 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": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.",
|
||||||
|
}
|
||||||
264
solution/backend/tests/test_kakao_webhook.py
Normal file
264
solution/backend/tests/test_kakao_webhook.py
Normal 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"] == "안녕"
|
||||||
Loading…
Reference in New Issue
Block a user