diff --git a/.env.example b/.env.example index e0c9cfa..407af15 100644 --- a/.env.example +++ b/.env.example @@ -93,7 +93,13 @@ AGENT_CHAT_ENABLED=1 # 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))" KAKAO_WEBHOOK_SECRET= # 우리 봇이 맞는지 한 겹 더. 오발송을 거르는 용도라 비워도 된다. +# ★ 단, Event API(챗봇이 먼저 보내기)를 쓰려면 이 값이 필수다. KAKAO_BOT_ID= +# Event API — 채널을 연결한 비즈니스 인증 앱의 REST API 키. 비우면 Event API 는 꺼진다. +# ★ 위 KAKAO_REST_API_KEY(카카오 로컬 API)와 다른 값일 수 있어 이름을 갈랐다. +KAKAO_BOT_REST_API_KEY= +# 1 이면 개발 채널로 보낸다(봇 ID 뒤에 "!"). 운영 채널이면 0. +KAKAO_EVENT_DEV=0 KAKAO_CHANNEL_PUBLIC_ID= KAKAO_LINK_CODE_TTL_MIN=10 KAKAO_LINK_MAX_ATTEMPTS=5 diff --git a/docs/AGENT.md b/docs/AGENT.md index a1e3ec9..7c5e74e 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -298,6 +298,12 @@ URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest. ★ **바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면 눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다. +## 발행 요청 응답 (2026-09-29) + +발행 작업이 접수되면 **"발행을 시작했습니다. 완료 후 아래 주소에서 확인해 주세요."** 와 +해당 사이트의 URL을 함께 응답한다. 주소는 `site_payload.publish_url`로 구해 발행 주소와 +같은 규칙을 쓴다. 완료를 기다리거나 별도 완료 알림을 보내지 않는다 — 이 응답은 접수 안내다. + ## 5초 벽 — 콜백으로 넘는다 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 **말없이 실패하는 봇**이 된다. @@ -342,3 +348,68 @@ https://<발행호스트>/v1/agent/kakao/webhook ★ **채널 '채팅' 과 '챗봇(오픈빌더 스킬)' 은 다른 기능이다.** 채팅만 켜면 발화가 우리에게 오지 않는다 — 웹훅이 붙는 쪽은 챗봇이고, 봇을 만들어 채널에 연결해야 한다. + +--- + +# 5단계 — 챗봇이 먼저 보내기 (Event API) — 승인 알림을 카톡으로 + +메일로 가던 미니블로그 승인 알림을 **연결된 카카오톡으로도** 보낸다(2026-09-29 결정). + +## 결정 + +| | 결정 | +|---|---| +| 발송 | **카톡과 메일 둘 다.** 카톡이 연결돼 있어도 메일을 같이 보낸다 — 카톡 발송은 채널 친구가 아니거나 차단했으면 실패하므로 메일이 누락을 막는다 | +| 승인 방식 | 메시지의 **[승인] [수정] 인라인 버튼** + 누른 사람이 **연결된 본인인지·그 글이 본인 가게 것인지 서버가 확인**. 링크가 없어 메신저 미리보기가 먼저 열어 승인되는 문제가 처음부터 없다. 승인은 기존 `PostService.approve_by_owner` 를 그대로 탄다 | +| 진행 | **1단계(이 절)**: 클라이언트 + 테스트 발송으로 규격 확인. 2단계: 버튼 응답·승인 처리·발송 연결 | + +## 1단계에서 만든 것 + +``` +services/external/kakao_event.py Event API 클라이언트 — 카카오 계약은 여기 한 곳 +scripts/kakao_event_send_test.py 테스트 발송(실제 카톡으로 한 건) +config/agent_config.py KAKAO_BOT_ID(필수화) · KAKAO_BOT_REST_API_KEY · KAKAO_EVENT_DEV +``` + +``` +POST https://bot-api.kakao.com/v2/bots/{botId}/talk (개발 채널이면 botId 뒤에 "!") +Authorization: KakaoAK {REST API 키} +{"event":{"name":"…","data":{…}}, "user":[{"type":"botUserKey","id":"…"}], "params":{…}} +→ {"taskId":"…","status":"SUCCESS", …} +``` + +- `event.data` 는 말풍선에서 `{{#current.event.data.<이름>}}` 으로, `params` 는 스킬 서버에 + `userRequest.params` 로 전달된다 — 2단계에서 글 ID 를 `params` 에 실어 보낸다. +- 수신자는 `botUserKey` 다. 웹훅의 `userRequest.user.id` 와 같은 값이라 + `owner_kakao_links.channel_user_key` 를 그대로 쓴다. +- ★ **`KAKAO_BOT_REST_API_KEY` 는 기존 `KAKAO_REST_API_KEY`(카카오 로컬 API, 주소 검색)와 + 일부러 갈랐다.** Event API 는 채널을 연결한 비즈니스 인증 앱의 키를 써야 해서 앱이 다를 수 있고, + 같은 이름이면 한쪽을 채울 때 다른 쪽이 조용히 켜지거나 틀린 키로 나간다. +- ★ `KAKAO_EVENT_DEV=1` 로 개발 채널을 가린다. `KAKAO_BOT_ID` 자체에 `!` 를 붙여 쓰지 않는다 — + 웹훅이 그 값으로 요청의 `bot.id` 를 대조한다. +- 예외 문구(`str(ex)`)에는 키·발화자 ID·응답 원문이 없다. 진단용 원문은 `KakaoEventError.detail`. + +## 콘솔에서 먼저 끝내야 하는 것 (코드로 못 한다) + +1. 카카오 디벨로퍼스: 앱 비즈니스 정보 심사 승인 + **카카오톡 채널 연결**(비즈니스 인증 채널과 앱) +2. **월렛 생성·연결** — Event API 는 발송 성공 건당 15원(VAT 별도) +3. 오픈빌더: 이벤트 정의(예: `post_approval`) → 이벤트 블록의 말풍선에 + `{{#current.event.data.text}}` → **배포** (배포 전에는 발송되지 않는다) +4. `.env`: `KAKAO_BOT_ID` · `KAKAO_BOT_REST_API_KEY` (개발 채널이면 `KAKAO_EVENT_DEV=1`) + +## 한계 (카카오 쪽 제약) + +- 사용자 식별값은 **사용자가 채널에 처음 말을 건 뒤에야 채번된다** — 연결 코드를 보낸 사장님만 받을 수 있다. +- 채널 친구가 아니거나 차단했으면 전송은 실패한다 → 메일이 같이 나가는 이유. + +## 테스트 발송 + +```bash +# botUserKey 는 연결된 사장님의 값이다 +# SELECT channel_user_key FROM owner_kakao_links WHERE status='LINKED' AND deleted=false; +cd solution/backend && .venv/bin/python scripts/kakao_event_send_test.py --key +# 서버(도커)에서는: docker compose exec solution-backend python scripts/kakao_event_send_test.py --key +``` + +성공하면 카톡에 이벤트 블록의 말풍선이 뜬다. 요청은 성공인데 안 오면 이벤트 블록 연결 · +배포 · 채널 친구 여부 순서로 본다. diff --git a/solution/backend/config/agent_config.py b/solution/backend/config/agent_config.py index 5fd4107..1bdb025 100644 --- a/solution/backend/config/agent_config.py +++ b/solution/backend/config/agent_config.py @@ -38,8 +38,18 @@ class AgentConfig(BaseSettings): # 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))" KAKAO_WEBHOOK_SECRET: str = "" # 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다. + # ★ Event API(챗봇이 먼저 보내기)는 이 값이 **필수**다 — 요청 주소에 봇 ID 가 들어간다. KAKAO_BOT_ID: str = "" + # ★ Event API — 연결된 사장님에게 챗봇이 먼저 말을 거는 통로(services/external/kakao_event.py). + # **채널을 연결한 비즈니스 인증 앱**의 REST API 키. 비어 있으면 Event API 는 존재하지 않는다. + # ★ 기존 KAKAO_REST_API_KEY(카카오 로컬 API, 주소 검색 어댑터)와 이름을 일부러 갈랐다 — + # 앱이 다를 수 있고, 같은 이름이면 한쪽을 채울 때 다른 쪽이 조용히 켜지거나 틀린 키로 나간다. + KAKAO_BOT_REST_API_KEY: str = "" + # "1" 이면 봇 ID 뒤에 "!" 를 붙여 **개발 채널**로 보낸다(운영 채널과 요청 주소가 다르다). + # ★ KAKAO_BOT_ID 자체를 고쳐 쓰지 않는다 — 웹훅이 그 값으로 요청의 bot.id 를 대조한다. + KAKAO_EVENT_DEV: str = "0" + def get(name, default=""): return getattr(AgentConfig(), name, default) or default diff --git a/solution/backend/scripts/kakao_event_send_test.py b/solution/backend/scripts/kakao_event_send_test.py new file mode 100644 index 0000000..6ea98b2 --- /dev/null +++ b/solution/backend/scripts/kakao_event_send_test.py @@ -0,0 +1,51 @@ +"""카카오 챗봇 Event API 테스트 발송 — 규격을 실제로 한 번 확인하는 1회성 스크립트. + + cd solution/backend && .venv/bin/python scripts/kakao_event_send_test.py --key + +★ 무엇을 확인하나: 콘솔 설정(채널 연결·월렛·이벤트 블록·배포)이 제대로 끝났는지, 그리고 + 요청/응답 모양이 문서와 같은지. 성공하면 카톡에 이벤트 블록의 말풍선이 뜬다. +★ botUserKey 는 그 사장님이 채널에 연결 코드를 보낸 뒤 owner_kakao_links.channel_user_key 에 + 저장된 값이다. 아직 연결 전이면 발송할 대상이 없다. + SELECT channel_user_key FROM owner_kakao_links WHERE status='LINKED' AND deleted=false; +★ 필요한 설정(.env): KAKAO_BOT_ID · KAKAO_BOT_REST_API_KEY (개발 채널이면 KAKAO_EVENT_DEV=1). + 키·발화자 ID 는 출력하지 않는다. +""" +import argparse +import asyncio +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +os.environ.setdefault("APP_ENV", "local") + +import httpx # noqa: E402 + +from services.external import kakao_event # noqa: E402 + + +async def main(key: str, event: str, text: str) -> int: + if not kakao_event.is_configured(): + print("KAKAO_BOT_ID · KAKAO_BOT_REST_API_KEY 가 .env 에 없습니다.") + return 1 + + async with httpx.AsyncClient(timeout=10.0) as client: + try: + task_id = await kakao_event.send( + key, event, data={"text": text}, params={"test": "1"}, client=client + ) + except kakao_event.KakaoEventError as ex: + print(f"실패 — {ex}") + print(f"카카오 응답: {ex.detail}") + return 1 + print(f"요청 성공 — taskId={task_id}") + print("카톡에 메시지가 왔는지 확인하세요. 안 오면 이벤트 블록 연결·배포·채널 친구 여부를 봅니다.") + return 0 + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--key", required=True, help="수신자 botUserKey") + parser.add_argument("--event", default="post_approval", help="오픈빌더에 정의한 이벤트 이름") + parser.add_argument("--text", default="Event API 테스트입니다.", help="event.data.text 로 실려 갈 문구") + args = parser.parse_args() + raise SystemExit(asyncio.run(main(args.key, args.event, args.text))) diff --git a/solution/backend/services/external/kakao_event.py b/solution/backend/services/external/kakao_event.py new file mode 100644 index 0000000..73b4032 --- /dev/null +++ b/solution/backend/services/external/kakao_event.py @@ -0,0 +1,70 @@ +"""카카오 챗봇 Event API — 연결된 사장님에게 챗봇이 **먼저** 말을 거는 유일한 통로. + +카카오 계약(주소·인증 헤더·요청 모양)은 여기 한 곳에만 둔다. 알림톡 어댑터(alimtalk.py)와 +같은 규칙이다 — 서비스 계층에 카카오 모양이 새면 채널을 바꿀 때 전부 걷어내야 한다. + +★ 사용자 식별값은 `botUserKey` 다. 오픈빌더 웹훅의 `userRequest.user.id` 와 같은 값이라 + `owner_kakao_links.channel_user_key` 를 그대로 쓴다. 사용자가 채널에 **처음 말을 건 뒤에야** + 채번되므로, 연결 코드를 보낸 사장님만 받을 수 있다(카카오 데브톡 답변). +★ 채널을 친구 추가하지 않았거나 차단했으면 전송은 실패한다 — 호출부가 다른 경로(메일)로 + 대체할 수 있도록 실패는 예외로 올린다. +★ 예외 문구에 REST 키·발화자 ID·응답 원문을 넣지 않는다. 이 문자열은 로그로 간다. + 진단용 원문은 `KakaoEventError.detail` 에만 담는다. +""" + +import httpx + +from config import agent_config as config + +BASE_URL = "https://bot-api.kakao.com/v2/bots" + + +class KakaoEventError(RuntimeError): + """Event API 호출 실패. `str()` 은 로그에 나가도 되는 코드뿐이고, 원문은 `detail`.""" + + def __init__(self, code: str, detail: str = ""): + super().__init__(code) + self.detail = detail + + +def is_configured() -> bool: + return bool(config.get("KAKAO_BOT_ID") and config.get("KAKAO_BOT_REST_API_KEY")) + + +def _url() -> str: + bot_id = config.get("KAKAO_BOT_ID") + if config.get("KAKAO_EVENT_DEV", "0") == "1": + bot_id += "!" + return f"{BASE_URL}/{bot_id}/talk" + + +async def send(bot_user_key: str, event_name: str, *, data: dict | None = None, + params: dict | None = None, client: httpx.AsyncClient) -> str: + """이벤트 블록을 호출해 그 사용자에게 메시지를 보낸다. 성공하면 taskId. + + `data` 는 말풍선 안에서 `{{#current.event.data.<이름>}}` 으로, `params` 는 스킬 서버에 + `userRequest.params` 로 전달된다.""" + if not is_configured(): + raise KakaoEventError("KAKAO_EVENT_NOT_CONFIGURED") + + event: dict = {"name": event_name} + if data: + event["data"] = data + body: dict = {"event": event, "user": [{"type": "botUserKey", "id": bot_user_key}]} + if params: + body["params"] = params + + res = await client.post( + _url(), + headers={"Authorization": f"KakaoAK {config.get('KAKAO_BOT_REST_API_KEY')}"}, + json=body, + ) + if res.status_code != 200: + raise KakaoEventError(f"KAKAO_EVENT_HTTP_{res.status_code}", res.text[:300]) + try: + payload = res.json() + except ValueError as ex: + raise KakaoEventError("KAKAO_EVENT_INVALID_RESPONSE", res.text[:300]) from ex + if payload.get("status") != "SUCCESS": + raise KakaoEventError("KAKAO_EVENT_REJECTED", res.text[:300]) + return str(payload.get("taskId") or "") diff --git a/solution/backend/tests/test_kakao_event.py b/solution/backend/tests/test_kakao_event.py new file mode 100644 index 0000000..7c7bc39 --- /dev/null +++ b/solution/backend/tests/test_kakao_event.py @@ -0,0 +1,121 @@ +"""카카오 챗봇 Event API — 연결된 사장님에게 챗봇이 먼저 말을 거는 통로. + +여기서 지키는 것 셋: + 1. 요청 모양(주소·인증 헤더·본문)이 공식 규격 그대로다 — 어긋나면 카카오가 조용히 거절한다 + 2. 실패는 예외로 올라온다 — 알림이 안 간 것을 성공으로 넘기면 승인 알림이 통째로 사라진다 + 3. 예외 문구에 키·발화자 ID 가 없다 — 이 문자열은 로그로 가고 로그는 우리만 보지 않는다 +""" + +import json + +import httpx +import pytest + +from services.external import kakao_event + +BOT_ID = "0123456789abcdef01234567" +REST_KEY = "test-rest-api-key-0123456789" +SPEAKER = "test-bot-user-key-abc" + + +@pytest.fixture(autouse=True) +def configured(monkeypatch): + monkeypatch.setenv("KAKAO_BOT_ID", BOT_ID) + monkeypatch.setenv("KAKAO_BOT_REST_API_KEY", REST_KEY) + monkeypatch.delenv("KAKAO_EVENT_DEV", raising=False) + + +def _client(handler) -> httpx.AsyncClient: + return httpx.AsyncClient(transport=httpx.MockTransport(handler)) + + +def test_not_configured_without_bot_id_or_rest_key(monkeypatch): + assert kakao_event.is_configured() is True + monkeypatch.setenv("KAKAO_BOT_REST_API_KEY", "") + assert kakao_event.is_configured() is False + monkeypatch.setenv("KAKAO_BOT_REST_API_KEY", REST_KEY) + monkeypatch.setenv("KAKAO_BOT_ID", "") + assert kakao_event.is_configured() is False + + +async def test_send_posts_the_documented_request_shape(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + seen["auth"] = request.headers["Authorization"] + seen["body"] = json.loads(request.content) + return httpx.Response(200, json={"taskId": "task-1", "status": "SUCCESS", "message": ""}) + + async with _client(handler) as client: + task_id = await kakao_event.send( + SPEAKER, "post_approval", data={"text": "hello"}, params={"post_id": "p-1"}, client=client + ) + + assert task_id == "task-1" + assert seen["url"] == f"https://bot-api.kakao.com/v2/bots/{BOT_ID}/talk" + assert seen["auth"] == f"KakaoAK {REST_KEY}" + assert seen["body"] == { + "event": {"name": "post_approval", "data": {"text": "hello"}}, + "user": [{"type": "botUserKey", "id": SPEAKER}], + "params": {"post_id": "p-1"}, + } + + +async def test_send_omits_data_and_params_when_not_given(): + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["body"] = json.loads(request.content) + return httpx.Response(200, json={"taskId": "task-2", "status": "SUCCESS"}) + + async with _client(handler) as client: + await kakao_event.send(SPEAKER, "post_approval", client=client) + + assert seen["body"]["event"] == {"name": "post_approval"} + assert "params" not in seen["body"] + + +async def test_dev_channel_appends_bang_to_bot_id(monkeypatch): + monkeypatch.setenv("KAKAO_EVENT_DEV", "1") + seen = {} + + def handler(request: httpx.Request) -> httpx.Response: + seen["url"] = str(request.url) + return httpx.Response(200, json={"taskId": "task-3", "status": "SUCCESS"}) + + async with _client(handler) as client: + await kakao_event.send(SPEAKER, "post_approval", client=client) + + assert seen["url"] == f"https://bot-api.kakao.com/v2/bots/{BOT_ID}!/talk" + + +async def test_http_error_raises_without_leaking_secrets(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(401, json={"message": "invalid key"}) + + async with _client(handler) as client: + with pytest.raises(kakao_event.KakaoEventError) as info: + await kakao_event.send(SPEAKER, "post_approval", client=client) + + assert "401" in str(info.value) + assert REST_KEY not in str(info.value) and SPEAKER not in str(info.value) + # 진단용 원문은 str 이 아니라 속성에만 — 로그로 가는 건 str(ex) 뿐이다. + assert "invalid key" in info.value.detail + + +async def test_non_success_status_raises(): + def handler(request: httpx.Request) -> httpx.Response: + return httpx.Response(200, json={"taskId": "task-4", "status": "FAIL", "message": "no event"}) + + async with _client(handler) as client: + with pytest.raises(kakao_event.KakaoEventError): + await kakao_event.send(SPEAKER, "post_approval", client=client) + + +async def test_send_refuses_when_not_configured(monkeypatch): + monkeypatch.setenv("KAKAO_BOT_REST_API_KEY", "") + + async with _client(lambda request: httpx.Response(200, json={})) as client: + with pytest.raises(kakao_event.KakaoEventError, match="NOT_CONFIGURED"): + await kakao_event.send(SPEAKER, "post_approval", client=client)