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