o2o-site-AEO/solution/backend/services/external/kakao_event.py
김성경 983c05b3c3 [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
2026-09-29 15:25:06 +09:00

71 lines
2.9 KiB
Python

"""카카오 챗봇 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 "")