diff --git a/.env.example b/.env.example index f216d96..e0c9cfa 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/AGENTS.md b/AGENTS.md index c199620..8643398 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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`). 키만 보면 "잠시 닫아 두기" 가 키를 지우는 일이 되어 소개문·사진분류까지 꺼지고, 스위치만 보면 키 없는 환경에 **눌러도 안 되는 입구**가 생긴다. diff --git a/docs/AGENT.md b/docs/AGENT.md index b498361..7e695e8 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -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/<시크릿>` 을 쓴다. + +★ **채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게 +오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다. diff --git a/docs/DEVLOG.md b/docs/DEVLOG.md index e8fbce8..a5dee69 100644 --- a/docs/DEVLOG.md +++ b/docs/DEVLOG.md @@ -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` 기본값을 diff --git a/postgres-init/init-data/init.sql b/postgres-init/init-data/init.sql index 419a87a..52e94df 100644 --- a/postgres-init/init-data/init.sql +++ b/postgres-init/init-data/init.sql @@ -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 diff --git a/postgres-init/migrations/0022_owner_kakao_links_conversation.sql b/postgres-init/migrations/0022_owner_kakao_links_conversation.sql new file mode 100644 index 0000000..b2f0447 --- /dev/null +++ b/postgres-init/migrations/0022_owner_kakao_links_conversation.sql @@ -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; diff --git a/solution/backend/common/database/model/models.py b/solution/backend/common/database/model/models.py index 81fdb5c..592342b 100644 --- a/solution/backend/common/database/model/models.py +++ b/solution/backend/common/database/model/models.py @@ -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'")), diff --git a/solution/backend/config/agent_config.py b/solution/backend/config/agent_config.py index eca935d..5fd4107 100644 --- a/solution/backend/config/agent_config.py +++ b/solution/backend/config/agent_config.py @@ -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")) diff --git a/solution/backend/router/router.py b/solution/backend/router/router.py index d87044b..cf46e52 100644 --- a/solution/backend/router/router.py +++ b/solution/backend/router/router.py @@ -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) diff --git a/solution/backend/router/v1/agent/kakao_bot.py b/solution/backend/router/v1/agent/kakao_bot.py new file mode 100644 index 0000000..58d1095 --- /dev/null +++ b/solution/backend/router/v1/agent/kakao_bot.py @@ -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) diff --git a/solution/backend/services/agent/channel.py b/solution/backend/services/agent/channel.py new file mode 100644 index 0000000..deddaae --- /dev/null +++ b/solution/backend/services/agent/channel.py @@ -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": "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요.", +} diff --git a/solution/backend/tests/test_kakao_webhook.py b/solution/backend/tests/test_kakao_webhook.py new file mode 100644 index 0000000..17c720b --- /dev/null +++ b/solution/backend/tests/test_kakao_webhook.py @@ -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"] == "안녕"