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