[feat] solution/backend: 카카오 챗봇 Event API 클라이언트와 테스트 발송 스크립트 (승인 알림 카톡 발송 1단계)

미니블로그 승인 알림을 메일에 더해 연결된 카카오톡으로도 보내기 위한 첫 단계.
승인 방식은 인라인 버튼 + 연결된 계정 신원 확인으로 정했고(링크가 없어 메신저
미리보기가 먼저 열어 승인되는 문제가 없다), 이 커밋은 발송 통로와 규격 확인까지다.

- services/external/kakao_event.py: POST bot-api.kakao.com/v2/bots/{botId}/talk.
  예외 문구(str)에는 키·발화자 ID·응답 원문을 넣지 않고 진단용은 detail 에만 둔다
- KAKAO_BOT_REST_API_KEY 를 기존 KAKAO_REST_API_KEY(카카오 로컬 API)와 갈랐다 —
  Event API 는 채널을 연결한 비즈니스 인증 앱의 키를 써야 해서 앱이 다를 수 있다
- KAKAO_EVENT_DEV=1 이면 봇 ID 뒤에 "!"(개발 채널). KAKAO_BOT_ID 자체는 웹훅이
  bot.id 대조에 쓰므로 고쳐 쓰지 않는다
- scripts/kakao_event_send_test.py: 실제 카톡으로 한 건 보내 규격을 확인하는 스크립트

실제 발송으로 확인함(요청 성공 + 카톡 수신). 이벤트 미배포 시 "Invalid Event name" 404 를
돌려주는 것도 확인했다.

test_kakao_event.py 7건, 카카오·설정 관련 스위트 75 passed
This commit is contained in:
김성경 2026-09-29 15:25:06 +09:00
parent 86d870bab5
commit 983c05b3c3
6 changed files with 329 additions and 0 deletions

View File

@ -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

View File

@ -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 <botUserKey>
# 서버(도커)에서는: docker compose exec solution-backend python scripts/kakao_event_send_test.py --key <botUserKey>
```
성공하면 카톡에 이벤트 블록의 말풍선이 뜬다. 요청은 성공인데 안 오면 이벤트 블록 연결 ·
배포 · 채널 친구 여부 순서로 본다.

View File

@ -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

View File

@ -0,0 +1,51 @@
"""카카오 챗봇 Event API 테스트 발송 — 규격을 실제로 한 번 확인하는 1회성 스크립트.
cd solution/backend && .venv/bin/python scripts/kakao_event_send_test.py --key <botUserKey>
★ 무엇을 확인하나: 콘솔 설정(채널 연결·월렛·이벤트 블록·배포)이 제대로 끝났는지, 그리고
요청/응답 모양이 문서와 같은지. 성공하면 카톡에 이벤트 블록의 말풍선이 뜬다.
★ 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)))

View File

@ -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 "")

View File

@ -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)