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