merge: origin/main(카톡 승인 알림 Event API) 을 fix/agent-multi-action 에 병합

충돌은 tests/test_kakao_webhook.py 하나 — 양쪽이 파일 끝에 서로 다른 테스트를 붙였다.
둘 다 살렸다(여러 가게·확인 경로 테스트 → 승인 알림 테스트 순).
channel.py 는 자동 병합 — 저쪽은 approval_notice·approve_post 추가와 _say 선택 인자 확장이라
이쪽의 _pick_place·확인 경로 변경과 겹치지 않는다.

전체 994 passed / 47 failed — 실패 목록은 병합 전과 동일(gemini·openai 키 미설정, search_console, weather_notes)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
민헌 2026-09-30 09:26:28 +09:00
commit 273efb717f
14 changed files with 1163 additions and 29 deletions

View File

@ -93,7 +93,21 @@ AGENT_CHAT_ENABLED=1
# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))" # 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))"
KAKAO_WEBHOOK_SECRET= KAKAO_WEBHOOK_SECRET=
# 우리 봇이 맞는지 한 겹 더. 오발송을 거르는 용도라 비워도 된다. # 우리 봇이 맞는지 한 겹 더. 오발송을 거르는 용도라 비워도 된다.
# ★ 단, Event API(챗봇이 먼저 보내기)를 쓰려면 이 값이 필수다.
KAKAO_BOT_ID= 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
# 미니블로그 승인 알림을 메일에 더해 연결된 카톡으로도 보낸다(Event API).
# ★ 오픈빌더에 이벤트 블록(스킬 연결)을 만들고 배포하기 전에는 켜지 않는다 — 켜면
# 사장님 카톡에 빈 말풍선이 간다. 기본 꺼짐.
KAKAO_APPROVAL_PUSH_ENABLED=0
KAKAO_APPROVAL_EVENT_NAME=post_approval
# [승인] 버튼이 부르는 블록 ID(오픈빌더 블록 주소의 /intent/<ID>). 그 블록에 우리 스킬이
# 스킬데이터로 연결돼 있어야 한다. ★ 비우면 [승인] 버튼을 그리지 않는다(수정하기만 나간다).
KAKAO_APPROVE_BLOCK_ID=
KAKAO_CHANNEL_PUBLIC_ID= KAKAO_CHANNEL_PUBLIC_ID=
KAKAO_LINK_CODE_TTL_MIN=10 KAKAO_LINK_CODE_TTL_MIN=10
KAKAO_LINK_MAX_ATTEMPTS=5 KAKAO_LINK_MAX_ATTEMPTS=5

View File

@ -395,6 +395,12 @@ URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest.
★ **바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면 ★ **바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면
눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다. 눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다.
## 발행 요청 응답 (2026-09-29)
발행 작업이 접수되면 **"발행을 시작했습니다. 완료 후 아래 주소에서 확인해 주세요."** 와
해당 사이트의 URL을 함께 응답한다. 주소는 `site_payload.publish_url`로 구해 발행 주소와
같은 규칙을 쓴다. 완료를 기다리거나 별도 완료 알림을 보내지 않는다 — 이 응답은 접수 안내다.
## 5초 벽 — 콜백으로 넘는다 ## 5초 벽 — 콜백으로 넘는다
오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 **말없이 실패하는 봇**이 된다. 오픈빌더의 스킬 타임아웃은 **5초**다. 넘기면 카카오가 끊어 **말없이 실패하는 봇**이 된다.
@ -439,3 +445,125 @@ 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>
```
성공하면 카톡에 이벤트 블록의 말풍선이 뜬다. 요청은 성공인데 안 오면 이벤트 블록 연결 ·
배포 · 채널 친구 여부 순서로 본다.
## 2-1 진행 — 발송과 메시지 그리기 (2026-09-29)
```
blog_jobs._send_one 메일 + 카톡 Event API(params: post_id, edit_token) — 하나라도 나가면 SENT
오픈빌더 이벤트 블록 스킬 데이터 응답 → 우리 웹훅(POST /v1/agent/kakao/webhook)
kakao_bot._handle userRequest.params.post_id 가 있으면 승인 알림 요청으로 처리
channel.approval_notice 연결된 본인 가게의 글일 때만 본문 + [수정하기] 링크
```
- 스위치는 `KAKAO_APPROVAL_PUSH_ENABLED`(기본 0), 이벤트 이름은 `KAKAO_APPROVAL_EVENT_NAME`(기본 `post_approval`).
- ★ **글 ID 는 믿지 않는다.** 발화자 키 → 연결된 사장님 → 그 글이 그 사장님 가게 것인지를 서버가
다시 본다. 연결 안 된 발화자·남의 글·이미 처리한 글·기한(그날 자정 KST) 지난 글은 **같은 안내**로
답한다 — 구분해 주면 글 ID 를 탐색할 수 있다.
- [수정하기] 는 메일의 '고쳐서 올리려면' 과 **같은 일회용 코드**다(`/v1/site/post/edit?t=`).
평문은 발송 시점에만 알아서 Event API `params.edit_token` 으로 넘긴다 — 어느 쪽이든 먼저
누른 쪽이 쓴다. 코드는 카카오 서버를 지나가므로 로그에는 params 의 **키만** 남기고 값은 남기지 않는다.
- 메일이 나갔으면 카톡보다 먼저 SENT 로 표시한다(웹훅이 곧바로 글을 읽는다). 카톡만 나가는
경우는 발송 성공 뒤에 표시하고, 웹훅은 REVIEWED 글도 읽는다.
- 링크 버튼은 `textCard` 로 본문(`simpleText`)과 따로 둔다 — 카드 설명 길이 제한에 글 문구가 걸리지 않게.
### 콘솔에서 바꿔야 하는 것
1. `post_approval` 이벤트 블록의 말풍선을 고정 문구(`{{#current.event.data.text}}`)에서
**스킬 데이터** 응답으로 바꾸고 우리 스킬을 연결한다(이벤트 블록에서도 스킬 서버를 쓸 수 있다).
2. 이벤트 블록에는 **필수 파라미터를 설정하지 않는다** — 설정하면 메시지가 발송되지 않는다.
3. 배포.
## 2-2 — [승인] 버튼 (2026-09-29)
```
알림 카드 [승인] (action: block, blockId=KAKAO_APPROVE_BLOCK_ID, extra={kind:approve, post_id})
→ 그 블록의 스킬 요청 body.action.clientExtra 로 extra 도착
→ kakao_bot._approve_click → channel.approve_post
→ 발화자 키 → 사장님 → 그 가게의 미처리·기한 전 글인지 재확인 → PostService.approve_by_owner
→ "올렸습니다" + [사이트 보기](#blog)
```
- ★ **승인 권한은 링크가 아니라 연결된 계정이다.** extra 의 글 ID 는 믿지 않는다. 연결 안 된
발화자·남의 글·이미 올린 글·기한 지난 글은 아무것도 승인하지 않고 같은 안내로 답한다.
이미 올린 글을 또 눌러도 같은 관문에서 걸려 두 번 올라가지 않는다.
- ★ `KAKAO_APPROVE_BLOCK_ID` 가 비면 [승인] 버튼을 그리지 않는다 — 콘솔 준비 전에 눌러도 안 되는
버튼을 사장님 카톡에 보내지 않는다.
- ★ 승인 처리는 LLM 을 부르지 않는다 — 결정적이어야 하고 유료 호출도 필요 없다.
- ★ 5초를 넘겨도 **승인 작업을 취소하지 않는다**(`asyncio.shield`). 승인은 상태 변경 → 재발행 잡 →
쓰레드 공유로 여러 번 커밋해서, 중간에 끊기면 승인만 되고 재발행이 안 걸린 글이 남는다.
응답만 "승인하고 있어요" 로 먼저 돌려준다.
- 로그에는 extra 의 **키만** 남긴다(`승인 클릭 — extra=[...]`) — 첫 실클릭에서 본문 모양을 확인한다.
### 콘솔
| 블록 | 이벤트 | 발화 | 필수 파라미터 | 봇 응답 |
|---|---|---|---|---|
| `미니 블로그 알림` | `post_approval` | 없음 | 없음 | 스킬데이터(web4ai-agent) |
| `미니 블로그 승인` | **없음** | 없음 | 없음 | 스킬데이터(web4ai-agent) |
`미니 블로그 승인` 블록 ID(주소의 `/intent/<ID>`)를 `KAKAO_APPROVE_BLOCK_ID` 에 넣는다.

View File

@ -122,6 +122,18 @@
수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id` 수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id`
까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post` 가 까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post` 가
`place_id` 불일치를 걸러낸다) `place_id` 불일치를 걸러낸다)
- **카톡 병행**(2026-09-29 결정: 카톡과 메일 **둘 다**): 사장님이 카카오톡을 연결했고
`KAKAO_APPROVAL_PUSH_ENABLED=1` 이면 메일에 더해 Event API 로도 보낸다
(`blog_jobs._push_kakao`, 규격·콘솔 준비는 `docs/AGENT.md` 5단계). 카톡은 채널 친구가
아니거나 차단했으면 실패하므로 메일을 빼지 않는다. **하나라도 나갔으면 SENT**, 아무 데도
안 나갔으면 SENT 로 표시하지 않아 다음 스윕이 다시 시도한다. 글 본문과 [수정하기] 링크는
오픈빌더 이벤트 블록의 스킬(우리 웹훅)이 그린다 — params 로 글 ID 와 수정용 일회용 코드가
가고, 웹훅이 **연결된 본인 가게의 글인지 다시 확인한 뒤에만** 본문을 준다
(`channel.approval_notice`).
- **카톡 [승인]**: 알림 카드의 [승인] 버튼(`KAKAO_APPROVE_BLOCK_ID` 가 있을 때만)을 누르면
연결된 계정이 권한이 된다 — 버튼이 들고 온 글 ID 는 믿지 않고, 누른 발화자의 가게 글·미처리·
기한 전인지 다시 본 뒤 메일·'바로 발행' 과 같은 `approve_by_owner` 로 올린다(재발행 잡 +
쓰레드 공유까지 동일). 답장에 [사이트 보기](발행 사이트의 `#blog`)를 붙인다.
## 5. 승인·수정 ## 5. 승인·수정

View File

@ -38,8 +38,27 @@ class AgentConfig(BaseSettings):
# 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))" # 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))"
KAKAO_WEBHOOK_SECRET: str = "" KAKAO_WEBHOOK_SECRET: str = ""
# 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다. # 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다.
# ★ Event API(챗봇이 먼저 보내기)는 이 값이 **필수**다 — 요청 주소에 봇 ID 가 들어간다.
KAKAO_BOT_ID: str = "" 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"
# 미니블로그 승인 알림을 메일에 더해 카톡으로도 보낸다. ★ 기본 꺼짐 — 오픈빌더에 이벤트
# 블록(스킬 연결)과 배포가 끝나기 전에 켜면 사장님 카톡에 빈 말풍선이 간다.
KAKAO_APPROVAL_PUSH_ENABLED: str = "0"
# 오픈빌더에 정의한 이벤트 이름. 말풍선을 그리는 블록이 이 이름에 걸려 있다.
KAKAO_APPROVAL_EVENT_NAME: str = "post_approval"
# [승인] 버튼이 누르면 부르는 블록의 ID(그 블록에 우리 스킬이 스킬데이터로 연결돼 있어야 한다 —
# 버튼의 extra 는 그 블록의 스킬 요청에 clientExtra 로 돌아온다). ★ 비어 있으면 [승인]
# 버튼을 아예 그리지 않는다 — 콘솔 준비 전에 눌러도 안 되는 버튼이 사장님 카톡에 나가면 안 된다.
KAKAO_APPROVE_BLOCK_ID: str = ""
def get(name, default=""): def get(name, default=""):
return getattr(AgentConfig(), name, default) or default return getattr(AgentConfig(), name, default) or default
@ -53,6 +72,14 @@ def webhook_secret() -> str:
return get("KAKAO_WEBHOOK_SECRET") return get("KAKAO_WEBHOOK_SECRET")
def approval_push_enabled() -> bool:
return get("KAKAO_APPROVAL_PUSH_ENABLED", "0") == "1"
def approve_block_id() -> str:
return get("KAKAO_APPROVE_BLOCK_ID")
def kakao_link_enabled() -> bool: def kakao_link_enabled() -> bool:
return bool(get("KAKAO_CHANNEL_PUBLIC_ID")) return bool(get("KAKAO_CHANNEL_PUBLIC_ID"))

View File

@ -40,11 +40,33 @@ CALLBACK_DEADLINE_SEC = 45.0
_TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요." _TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요."
_ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요." _ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해 주세요."
_WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요." _WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요."
_APPROVING_TEXT = "승인하고 있어요. 잠시 뒤 사이트에서 확인해 주세요."
_DEFAULT_HINT = "아래 버튼을 눌러 주세요."
_APPROVE_HINT = "이대로 올리려면 승인, 고쳐서 올리려면 수정하기를 눌러 주세요."
# 승인 버튼의 extra 에 실리는 종류 표지. 이 값이 아닌 clientExtra 는 승인으로 읽지 않는다.
_APPROVE_KIND = "approve"
def _reply(text: str, quick_replies=None) -> dict: def _reply(text: str, quick_replies=None, links=None, approve_post_id=None, hint=None) -> dict:
"""오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다.""" """오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다."""
payload: dict = {"outputs": [{"simpleText": {"text": text}}]} payload: dict = {"outputs": [{"simpleText": {"text": text}}]}
# 버튼은 카드에만 붙는다(simpleText 에는 버튼이 없다). 본문과 따로 둬 카드 설명 길이 제한
# (글 문구가 그 안에 안 들어갈 수 있다)에 걸리지 않게 한다.
buttons = []
block_id = config.approve_block_id()
if approve_post_id and block_id:
# ★ action "block" 의 extra 는 눌렀을 때 그 블록의 스킬 요청에 action.clientExtra 로 돌아온다.
# 블록 ID 가 비어 있으면 버튼을 그리지 않는다 — 눌러도 안 되는 버튼을 사장님께 보내지 않는다.
buttons.append({
"label": "승인", "action": "block", "blockId": block_id, "messageText": "승인",
"extra": {"kind": _APPROVE_KIND, "post_id": approve_post_id},
})
buttons += [{"label": link["label"], "action": "webLink", "webLinkUrl": link["url"]} for link in (links or [])]
if buttons:
description = _APPROVE_HINT if approve_post_id and block_id else (hint or _DEFAULT_HINT)
payload["outputs"].append({"textCard": {"description": description, "buttons": buttons[:3]}})
if quick_replies: if quick_replies:
# 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** — # 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** —
# channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다. # channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다.
@ -54,6 +76,14 @@ def _reply(text: str, quick_replies=None) -> dict:
return {"version": "2.0", "template": payload} return {"version": "2.0", "template": payload}
def _reply_from(answer: dict) -> dict:
"""channel 이 돌려준(카카오를 모르는) 답을 SkillResponse 로."""
return _reply(
answer["text"], answer.get("quick_replies"), answer.get("links"),
approve_post_id=answer.get("approve_post_id"), hint=answer.get("hint"),
)
def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict) -> None: def _authorize(secret_in_path: str | None, header_secret: str | None, body: dict) -> None:
expected = config.webhook_secret() expected = config.webhook_secret()
if not expected: if not expected:
@ -81,7 +111,51 @@ async def _answer(utterance: str, speaker: str, deadline: float) -> dict:
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다 except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}") LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
return _reply(_ERROR_TEXT) return _reply(_ERROR_TEXT)
return _reply(answer["text"], answer.get("quick_replies")) return _reply_from(answer)
async def _approval_notice(params: dict, speaker: str) -> dict:
"""Event API 로 시작된 승인 알림 요청. 글 ID·수정 코드는 우리가 이벤트를 보낼 때 params 로
실은 값이고, 누구에게 무엇을 보여줄지는 channel.approval_notice 가 다시 판단한다.
★ 5초 벽 안에서 끝나는 DB 조회뿐이라 콜백을 쓰지 않고 바로 답한다."""
try:
answer = await asyncio.wait_for(
channel.approval_notice(speaker, str(params.get("post_id") or ""), params.get("edit_token")),
timeout=DEADLINE_SEC,
)
except asyncio.TimeoutError:
LOG.w("[agent/kakao] 승인 알림 응답 시간 초과 — 안내로 끊음")
return _reply(_TIMEOUT_TEXT)
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
LOG.w(f"[agent/kakao] 승인 알림 처리 실패: {type(ex).__name__}")
return _reply(_ERROR_TEXT)
return _reply_from(answer)
def _log_task_failure(task: asyncio.Task) -> None:
if not task.cancelled() and task.exception() is not None:
LOG.w(f"[agent/kakao] 승인 처리 실패(응답 뒤): {type(task.exception()).__name__}")
async def _approve_click(extra: dict, speaker: str) -> dict:
"""[승인] 버튼 클릭. 글 ID 는 우리가 알림을 그릴 때 extra 에 실은 값이지만, 누가 무엇을
승인할 수 있는지는 channel.approve_post 가 다시 판단한다.
★ 5초를 넘겨도 **승인 작업을 취소하지 않는다.** 승인은 상태 변경 → 재발행 잡 적재 → 쓰레드
공유 순서로 여러 번 커밋해서, 중간에 끊기면 승인만 되고 재발행이 안 걸린 글이 남는다.
shield 로 응답만 먼저 돌려주고 작업은 끝까지 돈다."""
task = asyncio.ensure_future(channel.approve_post(speaker, str(extra.get("post_id") or "")))
try:
answer = await asyncio.wait_for(asyncio.shield(task), timeout=DEADLINE_SEC)
except asyncio.TimeoutError:
task.add_done_callback(_log_task_failure)
LOG.w("[agent/kakao] 승인 응답 시간 초과 — 작업은 계속 돈다")
return _reply(_APPROVING_TEXT)
except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다
LOG.w(f"[agent/kakao] 승인 처리 실패: {type(ex).__name__}")
return _reply(_ERROR_TEXT)
return _reply_from(answer)
async def _push(callback_url: str, utterance: str, speaker: str) -> None: async def _push(callback_url: str, utterance: str, speaker: str) -> None:
@ -107,6 +181,21 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
# 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다. # 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다.
return _reply("사용자를 확인하지 못했어요.") return _reply("사용자를 확인하지 못했어요.")
# ★ [승인] 버튼(action: block)의 extra 는 그 블록의 스킬 요청에 action.clientExtra 로 돌아온다.
# 글 ID 값은 남기지 않고 어떤 키가 왔는지만 남긴다.
extra = (body.get("action") or {}).get("clientExtra") or {}
if isinstance(extra, dict) and extra.get("kind") == _APPROVE_KIND and extra.get("post_id"):
LOG.i(f"[agent/kakao] 승인 클릭 — extra={sorted(extra)} block={(request.get('block') or {}).get('name')!r}")
return await _approve_click(extra, speaker)
# ★ 우리가 Event API 로 보낸 승인 알림이 이 요청을 시작시켰다면 params 에 글 ID 가 있다.
# 값(수정 코드는 비밀에 준한다)은 남기지 않고 어떤 키가 왔는지만 남긴다 — 실제 요청 본문이
# 문서와 같은지를 눈으로 가릴 수 있게.
params = request.get("params") or {}
if params.get("post_id"):
LOG.i(f"[agent/kakao] 승인 알림 요청 — params={sorted(params)} block={(request.get('block') or {}).get('name')!r}")
return await _approval_notice(params, speaker)
# ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다. # ★ 콜백이 켜져 있으면 5초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다.
callback_url = request.get("callbackUrl") callback_url = request.get("callbackUrl")
# ★ "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. 어느 블록이 도는지도 같이 — # ★ "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. 어느 블록이 도는지도 같이 —

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

@ -14,13 +14,14 @@
import re import re
import uuid import uuid
from datetime import datetime, timedelta, timezone from datetime import datetime, timedelta, timezone
from urllib.parse import quote
from sqlalchemy import select from sqlalchemy import select
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import owner_kakao_links as Link from common.database.model.models import owner_kakao_links as Link
from common.database.model.models import users from common.database.model.models import place_posts, places, users
from common.enums import DBWRType, ErrorType, KakaoLinkStatus from common.enums import DBWRType, ErrorType, KakaoLinkStatus, PostStatus
from common.models.gmodel import UserInfo from common.models.gmodel import UserInfo
from crud.place_crud import PlaceCRUD from crud.place_crud import PlaceCRUD
from crud.site_crud import SiteCRUD from crud.site_crud import SiteCRUD
@ -28,8 +29,9 @@ from crud.job_crud import JobQueue
from common.enums import SiteStatus from common.enums import SiteStatus
from common.models.gmodel import PageParams from common.models.gmodel import PageParams
from services.site_service import SiteService from services.site_service import SiteService
from services import kakao_link_service as link_service from services import blog_service, kakao_link_service as link_service
from services.agent import runtime from services.agent import runtime
from services.post_service import PostService
from services.agent.tools import REGISTRY from services.agent.tools import REGISTRY
from services.kakao_link_service import KakaoLinkError from services.kakao_link_service import KakaoLinkError
@ -60,9 +62,14 @@ def _now():
return datetime.now(timezone.utc) return datetime.now(timezone.utc)
def _say(text: str, quick: list[str] | None = None) -> dict: def _say(text: str, quick: list[str] | None = None, links: list[dict] | None = None,
"""채널이 모르는 모양으로 답한다 — 문구와 바로가기 목록뿐이다.""" approve_post_id: str | None = None, hint: str | None = None) -> dict:
return {"text": text, "quick_replies": quick or []} """채널이 모르는 모양으로 답한다 — 문구, 바로가기 목록, 링크 버튼({label, url}),
승인 버튼을 달 글 ID, 버튼 위에 붙는 한 줄 안내뿐이다. 버튼을 어떻게 그릴지는 채널 몫이다."""
return {
"text": text, "quick_replies": quick or [], "links": links or [],
"approve_post_id": approve_post_id, "hint": hint,
}
async def _user_info(user_id) -> UserInfo | None: async def _user_info(user_id) -> UserInfo | None:
@ -313,6 +320,117 @@ async def handle(utterance: str, channel_user_key: str) -> dict:
return _say(result["reply"], quick) return _say(result["reply"], quick)
# 승인 알림에서 보여줄 수 있는 글 = 아직 사이트에 안 올린 글. 메일 승인 링크와 같은 범위다
# (검수 통과 REVIEWED · 메일/카톡이 나간 SENT).
_APPROVABLE = (PostStatus.REVIEWED.value, PostStatus.SENT.value)
async def _owned_pending_post(user_id, post_id: str):
"""(글, 가게 이름) — **이 사장님 가게의**, 아직 안 올린, 기한이 안 지난 글일 때만. 아니면 None.
★ 글 ID 는 카카오가 이벤트 params 로 실어 온 값이지만 믿지 않는다. 발화자 키 → user_id 로
찾은 사장님이 그 글이 달린 가게의 주인인지를 여기서 다시 본다 — 글 ID 만 알면 남의 글이
보이는 구멍이 되면 안 된다(없는 글과 남의 글을 같은 답으로 돌려준다)."""
try:
pid = uuid.UUID(str(post_id))
except ValueError:
return None
async def run(s):
row = (
await s.execute(
select(place_posts, places.name)
.join(places, places.place_id == place_posts.place_id)
.where(
place_posts.post_id == pid,
place_posts.deleted.is_(False),
place_posts.status.in_(_APPROVABLE),
places.owner_user_id == user_id,
places.deleted.is_(False),
)
)
).first()
return ErrorType.SUCCESS, row
_err, row = await DB_SESSION_MNG.execute_lambda(place_posts.DBType(), DBWRType.DB_READ.value, run)
if row is None:
return None
post, name = row
expires = post.token_expires_at
if expires is not None:
# 메일 링크와 같은 만료(그날 자정 KST). DB 값이 tz 없이 오면 UTC 로 본다.
if expires.tzinfo is None:
expires = expires.replace(tzinfo=timezone.utc)
if expires < _now():
return None
return post, name
async def approval_notice(channel_user_key: str, post_id: str, edit_token: str | None = None) -> dict:
"""Event API 로 시작된 승인 알림을 그린다 — 글 문구와 [수정하기] 링크.
★ 연결된 본인의 글이 아니면 본문을 한 글자도 주지 않는다. 연결 안 된 발화자·남의 글·
이미 처리한 글·기한 지난 글은 구분하지 않고 같은 안내로 답한다(구분해 주면 글 ID 를
탐색할 수 있다). 예외를 던지지 않는 것은 handle() 과 같은 규약이다."""
row = await _link_row(channel_user_key)
if row is None:
return _say("연결된 계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.")
found = await _owned_pending_post(row.user_id, post_id)
if found is None:
return _say("이미 처리했거나 기한이 지난 글이에요.")
post, name = found
links = []
if edit_token:
# 메일의 '고쳐서 올리려면' 과 같은 일회용 코드다 — 어느 쪽이든 먼저 누른 쪽이 쓴다.
links = [{"label": "수정하기", "url": f"{blog_service.app_origin()}/v1/site/post/edit?t={quote(edit_token, safe='')}"}]
# 승인 버튼은 글 ID 만 실어 돌려주고, 그리는 건 채널 몫이다(블록 ID 같은 카카오 값이 여기 없다).
return _say(
f"[{name}] 이번 글을 올릴까요?\n\n{post.body}",
links=links,
approve_post_id=str(post.post_id),
hint="고쳐서 올리려면 아래 버튼을 눌러 주세요.", # 승인 버튼이 붙으면 채널이 문구를 바꾼다
)
async def approve_post(channel_user_key: str, post_id: str) -> dict:
"""카톡 [승인] 버튼 — 연결된 본인의 글일 때만 올린다.
★ 버튼이 들고 온 post_id 를 믿지 않는다. 발화자 키 → 사장님으로 찾은 뒤 그 글이 그
사장님 가게의 미처리·기한 전 글인지를 다시 본다(approval_notice 와 같은 관문).
틀리면 아무것도 승인하지 않고, 어느 쪽이 틀렸는지 구분하지 않고 같은 안내로 답한다.
★ 승인 자체는 메일 링크·빌더 '바로 발행' 과 같은 PostService.approve_by_owner 다 —
재발행 잡과 쓰레드 공유까지 세 경로가 똑같이 탄다. 이미 올린 글을 또 누르면 위 관문에서
걸려 두 번 올라가지 않는다. 예외를 던지지 않는 것은 handle() 과 같은 규약이다."""
row = await _link_row(channel_user_key)
if row is None:
return _say("연결된 계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.")
found = await _owned_pending_post(row.user_id, post_id)
if found is None:
return _say("이미 처리했거나 기한이 지난 글이에요.")
user = await _user_info(row.user_id)
if user is None:
return _say("계정을 찾지 못했어요. 관리자 화면에서 다시 연결해 주세요.")
post, name = found
service = PostService()
result = await service.approve_by_owner(user, str(post.place_id), str(post.post_id))
if not result.result.success:
return _say("지금은 올리지 못했어요. 잠시 뒤 다시 눌러 주세요.")
# 재발행은 몇 분 걸린다 — 메일 승인 확인 화면과 같이, 어디로 가면 보이는지를 바로 알려준다.
url = await service.blog_url(post.place_id)
links = [{"label": "사이트 보기", "url": url}] if url else []
return _say(
f"[{name}] 올렸습니다.\n사이트에 반영되기까지 몇 분 걸려요.",
links=links,
hint="반영되면 아래 버튼으로 확인해 보세요.",
)
_ERRORS = { _ERRORS = {
"PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.", "PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.",
"AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.", "AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.",

View File

@ -11,15 +11,17 @@
import uuid import uuid
from datetime import date, datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
import httpx
from sqlalchemy import select from sqlalchemy import select
from config import social_config from config import agent_config, social_config
from common.database.db_session_manager import DB_SESSION_MNG from common.database.db_session_manager import DB_SESSION_MNG
from common.database.model.models import place_posts, places, sites, users from common.database.model.models import place_posts, places, sites, users
from common.enums import DBWRType, PostStatus, SiteStatus from common.enums import DBWRType, PostStatus, SiteStatus
from common.logger import LOG from common.logger import LOG
from crud.post_crud import PostCRUD from crud.post_crud import PostCRUD
from services import blog_service, mail_service, site_payload from services import blog_service, kakao_link_service, mail_service, site_payload
from services.external import kakao_event
from services.snapshot import build_snapshot from services.snapshot import build_snapshot
BATCH_SIZE = 30 BATCH_SIZE = 30
@ -225,29 +227,90 @@ def _app_origin() -> str:
return blog_service.app_origin() return blog_service.app_origin()
def _kakao_push_ready() -> bool:
"""카톡 승인 알림을 보낼 수 있는 환경인가. DB 를 안 본다 — 스윕 맨 앞에서 싸게 거른다."""
return agent_config.approval_push_enabled() and kakao_event.is_configured()
async def _kakao_target(user) -> str | None:
"""이 사장님에게 카톡으로 보낼 수 있으면 발화자 키, 아니면 None(스위치 꺼짐·미연결·끊김)."""
if not _kakao_push_ready():
return None
return await kakao_link_service.linked_key(user.user_id)
def _can_mail(place, user) -> bool:
return mail_service.is_configured() and mail_service.is_valid_address(_notify_address(place, user) or "")
async def _push_kakao(channel_user_key: str, post, edit_token: str) -> bool:
"""승인 알림을 카톡으로. 실패해도 예외를 올리지 않는다 — 친구가 아니거나 차단했으면
실패하는 게 정상 경로라, 호출부가 메일로 대신할 수 있어야 한다.
★ 글 본문·수정 링크는 여기서 만들지 않는다. 오픈빌더 이벤트 블록의 스킬(우리 웹훅)이
params 의 글 ID 로 **연결된 본인 글인지 다시 확인한 뒤** 그린다. 수정 링크용 일회용
코드는 평문을 지금밖에 모르므로 params 로 넘긴다(메일과 같은 코드다)."""
try:
async with httpx.AsyncClient(timeout=10.0) as client:
await kakao_event.send(
channel_user_key,
agent_config.get("KAKAO_APPROVAL_EVENT_NAME", "post_approval"),
params={"post_id": str(post.post_id), "edit_token": edit_token},
client=client,
)
return True
except kakao_event.KakaoEventError as ex:
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림 실패: {ex}")
except httpx.HTTPError as ex:
LOG.w(f"[blog] post={post.post_id} 카톡 승인 알림 실패: {type(ex).__name__}")
return False
async def _send_one(place, user, post) -> bool: async def _send_one(place, user, post) -> bool:
"""토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시. 실패하면 DB 를 안 건드린다.""" """토큰 발급 → 메일·카톡 발송 → 하나라도 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면
DB 를 안 건드린다(다시 시도할 길을 막지 않는다).
★ 카톡과 메일은 **둘 다** 보낸다(2026-09-29 결정). 카톡은 채널 친구가 아니거나 차단했으면
실패하므로, 카톡이 붙어도 메일을 빼지 않는다."""
token, token_hash, expires = blog_service.issue_token() token, token_hash, expires = blog_service.issue_token()
# ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고, # ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고,
# 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023). # 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023).
edit_token, edit_hash, _edit_expires = blog_service.issue_token() edit_token, edit_hash, _edit_expires = blog_service.issue_token()
body = _mail_body(
place_name=place.name, post=post, user=user, mail_ok = False
origin=_app_origin(), approve_token=token, edit_token=edit_token, if _can_mail(place, user):
) body = _mail_body(
ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body) place_name=place.name, post=post, user=user,
if not ok: origin=_app_origin(), approve_token=token, edit_token=edit_token,
return False )
await DB_SESSION_MNG.execute_lambda_run( mail_ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body)
[place_posts.DBType()],
[lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)], async def mark_sent():
) await DB_SESSION_MNG.execute_lambda_run(
return True [place_posts.DBType()],
[lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)],
)
# ★ 메일이 나갔으면 카톡보다 **먼저** SENT 로 표시한다. 이벤트가 나가자마자 웹훅이 이 글을
# 읽는데, 그때 수정 코드 해시가 저장돼 있으면 [수정하기] 가 바로 먹는다. 카톡만 나가는
# 경우는 발송이 성공해야 표시할 수 있어 그 뒤에 한다 — 사장님이 버튼을 누르기까지의
# 시간이 그 간격보다 훨씬 길다(웹훅은 REVIEWED 글도 읽는다).
if mail_ok:
await mark_sent()
kakao_key = await _kakao_target(user)
if kakao_key is None:
return mail_ok
kakao_ok = await _push_kakao(kakao_key, post, edit_token)
if kakao_ok and not mail_ok:
await mark_sent()
return mail_ok or kakao_ok
async def send_reviewed() -> int: async def send_reviewed() -> int:
"""검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다.""" """검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다."""
if not mail_service.is_configured(): if not mail_service.is_configured() and not _kakao_push_ready():
return 0 return 0
_err, rows = await DB_SESSION_MNG.execute_lambda( _err, rows = await DB_SESSION_MNG.execute_lambda(
@ -264,7 +327,8 @@ async def send_reviewed() -> int:
if not target: if not target:
continue continue
place, user = target place, user = target
if not mail_service.is_valid_address(_notify_address(place, user) or ""): # 메일도 카톡도 보낼 수 없는 사장님은 건너뛴다. 하나라도 되면 _send_one 이 갈래를 정한다.
if not _can_mail(place, user) and await _kakao_target(user) is None:
continue continue
if await _send_one(place, user, post): if await _send_one(place, user, post):
sent += 1 sent += 1
@ -275,7 +339,7 @@ async def send_now_for_place(place_id: str) -> dict:
"""사장님이 빌더 화면에서 누르는 즉시 발송 — 아침 9시 스윕을 기다리지 않고 이 업장의 """사장님이 빌더 화면에서 누르는 즉시 발송 — 아침 9시 스윕을 기다리지 않고 이 업장의
오늘 몫을 지금 보낸다(2026-09-21, 사장님 요청: "지금 바로 발송할 수 있도록"). 오늘 몫을 지금 보낸다(2026-09-21, 사장님 요청: "지금 바로 발송할 수 있도록").
'하루 한 통' 원칙은 그대로다 — 이미 오늘 보냈으면(REVIEWED 가 아니면) 보낼 게 없다.""" '하루 한 통' 원칙은 그대로다 — 이미 오늘 보냈으면(REVIEWED 가 아니면) 보낼 게 없다."""
if not mail_service.is_configured(): if not mail_service.is_configured() and not _kakao_push_ready():
return {"sent": False, "reason": "MAIL_NOT_CONFIGURED"} return {"sent": False, "reason": "MAIL_NOT_CONFIGURED"}
place = user = None place = user = None
@ -293,7 +357,7 @@ async def send_now_for_place(place_id: str) -> dict:
if post is None: if post is None:
return {"sent": False, "reason": "NOTHING_DUE"} return {"sent": False, "reason": "NOTHING_DUE"}
if not mail_service.is_valid_address(_notify_address(place, user) or ""): if not _can_mail(place, user) and await _kakao_target(user) is None:
return {"sent": False, "reason": "NO_VALID_EMAIL"} return {"sent": False, "reason": "NO_VALID_EMAIL"}
if not await _send_one(place, user, post): if not await _send_one(place, user, post):

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

@ -179,6 +179,28 @@ async def resolve(channel_user_key: str) -> UUID | None:
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run) return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
async def linked_key(user_id) -> str | None:
"""사장님에게 연결된 카톡 발화자 키(botUserKey). 연결이 없거나 끊겼으면 None.
★ 승인 알림을 **먼저 보낼** 때 쓴다(Event API). resolve() 와 방향이 반대다 —
그건 들어온 발화자로 사장님을 찾는다. 여기서는 사장님으로 발화자를 찾는다."""
async def run(s):
row = (
await s.execute(
select(Link).where(
Link.user_id == user_id,
Link.deleted.is_(False),
Link.status == KakaoLinkStatus.LINKED.value,
Link.channel_user_key.is_not(None),
)
)
).scalars().first()
return row.channel_user_key if row is not None else None
return await DB_SESSION_MNG.execute_lambda_write(Link.DBType(), run)
async def disconnect(user_id: UUID) -> None: async def disconnect(user_id: UUID) -> None:
"""연결을 끊는다. 행은 REVOKED 로 남긴다 — 지우면 누가 언제 연결했는지가 사라진다. """연결을 끊는다. 행은 REVOKED 로 남긴다 — 지우면 누가 언제 연결했는지가 사라진다.

View File

@ -135,10 +135,10 @@ class PostService:
return { return {
"success": True, "success": True,
"message": APPROVED, "message": APPROVED,
"redirect_url": await self._blog_url(place_id), "redirect_url": await self.blog_url(place_id),
} }
async def _blog_url(self, place_id) -> str | None: async def blog_url(self, place_id) -> str | None:
"""이 업장의 발행된 사이트에서 미니 블로그가 보이는 자리. 승인 확인 화면이 몇 초 """이 업장의 발행된 사이트에서 미니 블로그가 보이는 자리. 승인 확인 화면이 몇 초
뒤 여기로 자동 연결한다(2026-09-22, 사장님 지시) — 사장님이 승인만 하고 실제로 뒤 여기로 자동 연결한다(2026-09-22, 사장님 지시) — 사장님이 승인만 하고 실제로
어디에 올라갔는지 못 찾는 걸 줄인다. 사이트가 없거나 아직 미발행이면 None — 어디에 올라갔는지 못 찾는 걸 줄인다. 사이트가 없거나 아직 미발행이면 None —

View File

@ -459,6 +459,150 @@ async def test_send_now_cannot_be_triggered_for_someone_elses_place(client, db_e
assert res.json()["result"]["success"] is False assert res.json()["result"]["success"] is False
async def _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, login_id, name):
"""발행된 사이트 + 메일 주소 + 카톡이 연결된 사장님. 카톡 Event API 는 켜 둔다."""
from services import kakao_link_service
monkeypatch.setenv("KAKAO_CHANNEL_PUBLIC_ID", "_testCh")
monkeypatch.setenv("KAKAO_APPROVAL_PUSH_ENABLED", "1")
monkeypatch.setenv("KAKAO_BOT_ID", "0123456789abcdef01234567")
monkeypatch.setenv("KAKAO_BOT_REST_API_KEY", "test-rest-api-key")
h = await auth_headers(login_id)
place_id = await _place(client, h, name=name)
async with db_engine.begin() as conn:
await conn.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, :st)"),
{"sid": uuid.uuid4(), "pid": place_id, "dom": f"blog-test-{uuid.uuid4().hex[:8]}", "st": SiteStatus.PUBLISHED.value},
)
await conn.execute(text("UPDATE users SET email = :e WHERE id = :id"), {"e": f"{login_id}@example.com", "id": login_id})
user_id = (await conn.execute(text("SELECT user_id FROM users WHERE id = :id"), {"id": login_id})).scalar_one()
await kakao_link_service.redeem((await kakao_link_service.issue_code(user_id))["code"], f"speaker-{login_id}")
return place_id, f"speaker-{login_id}", h
def _record_kakao_send(monkeypatch, *, fail=False):
from services.external import kakao_event
calls = []
async def fake_send(bot_user_key, event_name, *, data=None, params=None, client):
calls.append({"key": bot_user_key, "event": event_name, "params": params})
if fail:
raise kakao_event.KakaoEventError("KAKAO_EVENT_HTTP_404")
return "task-1"
monkeypatch.setattr(kakao_event, "send", fake_send)
return calls
async def test_send_reviewed_also_pushes_to_the_linked_kakao(client, db_engine, auth_headers, monkeypatch):
"""승인 알림은 메일에 더해 연결된 카카오톡으로도 나간다(2026-09-29, 결정: 카톡과 메일 둘 다).
카톡에는 글 ID 와 수정 링크용 일회용 코드가 params 로 실려, 웹훅이 메시지를 그린다."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
mails = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: mails.append(kwargs) or True)
kakao = _record_kakao_send(monkeypatch)
place_id, speaker, _h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend1", "카톡발송펜션")
post_id = await _seed_post(db_engine, place_id, scheduled=date.today())
sent = await blog_jobs.send_reviewed()
assert sent == 1
assert len(mails) == 1
assert len(kakao) == 1
assert kakao[0]["key"] == speaker
assert kakao[0]["event"] == "post_approval"
assert kakao[0]["params"]["post_id"] == post_id
assert len(kakao[0]["params"]["edit_token"]) >= 8
assert await _status(db_engine, post_id) == PostStatus.SENT.value
async def test_a_failed_kakao_push_does_not_block_the_mail(client, db_engine, auth_headers, monkeypatch):
"""카톡은 채널 친구가 아니거나 차단했으면 실패한다 — 그래서 메일도 같이 보낸다."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
mails = []
monkeypatch.setattr(mail_service, "send", lambda **kwargs: mails.append(kwargs) or True)
kakao = _record_kakao_send(monkeypatch, fail=True)
place_id, _speaker, _h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend2", "카톡실패펜션")
post_id = await _seed_post(db_engine, place_id, scheduled=date.today())
sent = await blog_jobs.send_reviewed()
assert sent == 1
assert len(kakao) == 1
assert len(mails) == 1
assert await _status(db_engine, post_id) == PostStatus.SENT.value
async def test_kakao_alone_is_enough_when_mail_is_unavailable(client, db_engine, auth_headers, monkeypatch):
"""메일 설정이 없어도 카톡이 나갔으면 보낸 것이다 — SENT 로 표시하고 하루 한 통 원칙을 지킨다."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: False)
kakao = _record_kakao_send(monkeypatch)
place_id, _speaker, _h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend3", "카톡만펜션")
post_id = await _seed_post(db_engine, place_id, scheduled=date.today())
sent = await blog_jobs.send_reviewed()
assert sent == 1
assert len(kakao) == 1
assert await _status(db_engine, post_id) == PostStatus.SENT.value
async def test_kakao_failure_alone_leaves_the_post_unsent_for_a_retry(client, db_engine, auth_headers, monkeypatch):
"""메일이 없고 카톡도 실패했으면 아무 데도 안 간 것이다 — SENT 로 표시하면 다시 시도할 길이 막힌다."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: False)
_record_kakao_send(monkeypatch, fail=True)
place_id, _speaker, _h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend4", "카톡실패만펜션")
post_id = await _seed_post(db_engine, place_id, scheduled=date.today())
sent = await blog_jobs.send_reviewed()
assert sent == 0
assert await _status(db_engine, post_id) == PostStatus.REVIEWED.value
async def test_kakao_push_is_off_unless_switched_on(client, db_engine, auth_headers, monkeypatch):
"""KAKAO_APPROVAL_PUSH_ENABLED 가 꺼져 있으면(기본) 연결돼 있어도 카톡으로 안 보낸다."""
from services import blog_jobs, mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
monkeypatch.setattr(mail_service, "send", lambda **kwargs: True)
kakao = _record_kakao_send(monkeypatch)
place_id, _speaker, _h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend5", "카톡꺼짐펜션")
monkeypatch.setenv("KAKAO_APPROVAL_PUSH_ENABLED", "0")
await _seed_post(db_engine, place_id, scheduled=date.today())
sent = await blog_jobs.send_reviewed()
assert sent == 1
assert kakao == []
async def test_send_now_also_pushes_to_kakao(client, db_engine, auth_headers, monkeypatch):
"""'승인 알림보내기' 버튼도 같은 경로다 — 메일만 가고 카톡은 빠지면 안 된다."""
from services import mail_service
monkeypatch.setattr(mail_service, "is_configured", lambda: True)
monkeypatch.setattr(mail_service, "send", lambda **kwargs: True)
kakao = _record_kakao_send(monkeypatch)
place_id, _speaker, h = await _publish_site_and_link_kakao(client, db_engine, auth_headers, monkeypatch, "kakaosend6", "카톡즉시펜션")
await _seed_post(db_engine, place_id, scheduled=date.today())
res = await client.post(f"/v1/place/{place_id}/post/send-now", headers=h)
assert res.json()["result"]["success"] is True
assert len(kakao) == 1
async def _day_pass_headers(db_engine, login_id: str) -> dict: async def _day_pass_headers(db_engine, login_id: str) -> dict:
"""메일의 '수정하려면' 링크가 주는 것과 같은 종류의 day-pass 토큰(2026-09-21, """메일의 '수정하려면' 링크가 주는 것과 같은 종류의 day-pass 토큰(2026-09-21,
사장님 지시: "메일 링크에서 들어와 수정한 후에는 승인할 수 있어야 한다").""" 사장님 지시: "메일 링크에서 들어와 수정한 후에는 승인할 수 있어야 한다")."""

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)

View File

@ -425,3 +425,277 @@ async def test_확인을_눌렀는데_가게가_없으면_그렇다고_말한다
) )
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("네", speaker)) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("네", speaker))
assert said(res) == channel._ERRORS["PLACE_NOT_FOUND"] assert said(res) == channel._ERRORS["PLACE_NOT_FOUND"]
# ── 승인 알림(Event API 로 시작된 스킬 요청) ─────────────────────────────
# ★ 이 자리가 지키는 것: 글 본문은 **연결된 본인의 글일 때만** 나간다. 이벤트 요청의
# params 는 카카오가 채우지만, 발화자 키로 사장님을 찾고 그 글이 그 사장님 가게 것인지
# 서버가 다시 본다 — 글 ID 만 알면 남의 글이 보이는 구멍이 되면 안 된다.
POST_BODY = "비가 그친 뒤 마당 돌이 촉촉합니다. 대청마루에 앉아 빗소리를 들어 보세요."
def event_body(post_id, edit_token="EDITCODE1234", speaker="kakao-speaker-1"):
payload = body("", speaker)
payload["userRequest"]["params"] = {"post_id": str(post_id)}
if edit_token:
payload["userRequest"]["params"]["edit_token"] = edit_token
return payload
async def seed_post(db_engine, place_id, status=3, expires=None):
"""status 3 = SENT. expires 는 tz 를 단 UTC 로 넣는다(raw text() 로 timestamptz 를 바인딩할 때의 함정)."""
post_id = uuid.uuid4()
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO place_posts (post_id, place_id, body, topic_kind, topic_key, status, token_expires_at) "
"VALUES (:id, :pid, :body, 1, :key, :st, :exp)"),
{"id": post_id, "pid": uuid.UUID(str(place_id)), "body": POST_BODY,
"key": f"weather:{post_id.hex[:6]}", "st": status, "exp": expires},
)
return post_id
def outputs(res):
return res.json()["template"]["outputs"]
async def test_승인_알림은_본인_글이면_본문과_수정_링크를_그린다(client, auth_headers, db_engine):
h, pid, uid = await link(db_engine, client, auth_headers, "승인-발화자", "알림숙소")
post_id = await seed_post(db_engine, pid)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="승인-발화자"))
assert res.status_code == 200
first = outputs(res)[0]["simpleText"]["text"]
assert "알림숙소" in first and POST_BODY in first
button = outputs(res)[1]["textCard"]["buttons"][0]
assert button["label"] == "수정하기" and button["action"] == "webLink"
assert button["webLinkUrl"].endswith("/v1/site/post/edit?t=EDITCODE1234")
async def test_승인_알림은_수정_코드가_없으면_버튼_없이_본문만_준다(client, auth_headers, db_engine):
h, pid, uid = await link(db_engine, client, auth_headers, "코드없는-발화자")
post_id = await seed_post(db_engine, pid)
res = await client.post(
PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, edit_token=None, speaker="코드없는-발화자")
)
assert POST_BODY in outputs(res)[0]["simpleText"]["text"]
assert len(outputs(res)) == 1
async def test_남의_글은_본문을_주지_않는다(client, auth_headers, db_engine):
"""★ 글 ID 를 알아도 그 글의 주인에게 연결된 발화자가 아니면 아무것도 안 나온다."""
_h1, pid1, _uid1 = await link(db_engine, client, auth_headers, "주인-발화자", "주인숙소")
await link(db_engine, client, auth_headers, "남-발화자", "남의숙소")
post_id = await seed_post(db_engine, pid1)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="남-발화자"))
assert res.status_code == 200
assert POST_BODY not in res.text and "주인숙소" not in res.text
assert "EDITCODE1234" not in res.text
async def test_연결되지_않은_발화자에게는_본문을_주지_않는다(client, auth_headers, db_engine):
_h, pid, _uid = await link(db_engine, client, auth_headers, "연결된-발화자")
post_id = await seed_post(db_engine, pid)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="모르는-발화자"))
assert res.status_code == 200
assert POST_BODY not in res.text and "EDITCODE1234" not in res.text
async def test_이미_처리했거나_기한이_지난_글은_안내로_끝난다(client, auth_headers, db_engine):
from datetime import datetime, timedelta, timezone
h, pid, uid = await link(db_engine, client, auth_headers, "지난-발화자")
approved = await seed_post(db_engine, pid, status=4)
expired = await seed_post(db_engine, pid, expires=datetime.now(timezone.utc) - timedelta(hours=1))
for post_id in (approved, expired):
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="지난-발화자"))
assert res.status_code == 200
assert POST_BODY not in res.text
assert "기한" in said(res) or "처리" in said(res)
async def test_글_id_가_이상해도_터지지_않는다(client, auth_headers, db_engine):
await link(db_engine, client, auth_headers, "이상한-발화자")
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body("not-a-uuid", speaker="이상한-발화자"))
assert res.status_code == 200
assert said(res)
# ── [승인] 버튼 ──────────────────────────────────────────────────────────
# ★ 승인 권한은 링크가 아니라 **연결된 계정**이다. 버튼이 들고 오는 post_id 는 믿지 않고,
# 누른 발화자 → 사장님 → 그 글이 그 사장님 가게의 미처리·기한 전 글인지를 서버가 다시 본다.
# 틀리면 아무것도 승인되지 않고, 어느 쪽이 틀렸는지는 구분해 답하지 않는다.
APPROVE_BLOCK = "6abb6a319e1c4d176fa81ff4"
BUILD = 4 # JobType.BUILD
def click_body(post_id, speaker, kind="approve"):
"""[승인] 버튼(action: block)을 누르면 다음 스킬 요청의 action.clientExtra 로 extra 가 돌아온다."""
payload = body("승인", speaker)
payload["action"] = {"clientExtra": {"kind": kind, "post_id": str(post_id)}}
return payload
async def status_of(db_engine, post_id):
async with db_engine.begin() as c:
return (await c.execute(text("SELECT status FROM place_posts WHERE post_id=:p"), {"p": post_id})).scalar_one()
async def build_jobs(db_engine, place_id):
async with db_engine.begin() as c:
return (
await c.execute(
text("SELECT count(*) FROM jobs WHERE job_type=:t AND payload->>'place_id'=:p"),
{"t": BUILD, "p": str(place_id)},
)
).scalar_one()
async def publish_site(db_engine, place_id, domain="approve-click-test"):
async with db_engine.begin() as c:
await c.execute(
text("INSERT INTO sites (site_id, place_id, domain, status) VALUES (:sid, :pid, :dom, 3)"),
{"sid": uuid.uuid4(), "pid": uuid.UUID(str(place_id)), "dom": domain},
)
def card_buttons(res):
return outputs(res)[1]["textCard"]["buttons"]
async def test_승인_알림에_블록_id_가_있으면_승인_버튼을_그린다(client, auth_headers, db_engine, monkeypatch):
monkeypatch.setenv("KAKAO_APPROVE_BLOCK_ID", APPROVE_BLOCK)
h, pid, uid = await link(db_engine, client, auth_headers, "버튼-발화자", "버튼숙소")
post_id = await seed_post(db_engine, pid)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="버튼-발화자"))
buttons = card_buttons(res)
approve = next(b for b in buttons if b["label"] == "승인")
assert approve["action"] == "block" and approve["blockId"] == APPROVE_BLOCK
assert approve["extra"] == {"kind": "approve", "post_id": str(post_id)}
assert any(b["label"] == "수정하기" and b["action"] == "webLink" for b in buttons)
async def test_블록_id_가_없으면_죽은_승인_버튼을_그리지_않는다(client, auth_headers, db_engine, monkeypatch):
"""콘솔 준비 전에 켜도 눌러도 안 되는 버튼이 사장님 카톡에 나가지 않는다."""
monkeypatch.setenv("KAKAO_APPROVE_BLOCK_ID", "")
h, pid, uid = await link(db_engine, client, auth_headers, "버튼없는-발화자")
post_id = await seed_post(db_engine, pid)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=event_body(post_id, speaker="버튼없는-발화자"))
assert [b["label"] for b in card_buttons(res)] == ["수정하기"]
async def test_승인_버튼을_누르면_올라가고_사이트_보기를_준다(client, auth_headers, db_engine):
h, pid, uid = await link(db_engine, client, auth_headers, "승인-클릭자", "클릭숙소")
await publish_site(db_engine, pid)
post_id = await seed_post(db_engine, pid)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "승인-클릭자"))
assert res.status_code == 200
assert "올렸습니다" in said(res)
assert await status_of(db_engine, post_id) == 4 # APPROVED
assert await build_jobs(db_engine, pid) == 1
button = card_buttons(res)[0]
assert button["label"] == "사이트 보기" and button["action"] == "webLink"
assert button["webLinkUrl"].endswith("/s/approve-click-test#blog")
async def test_발행된_사이트가_없어도_승인은_되고_링크만_빠진다(client, auth_headers, db_engine):
h, pid, uid = await link(db_engine, client, auth_headers, "사이트없는-클릭자")
post_id = await seed_post(db_engine, pid)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "사이트없는-클릭자"))
assert "올렸습니다" in said(res)
assert await status_of(db_engine, post_id) == 4
assert len(outputs(res)) == 1
async def test_남의_글의_승인_버튼은_아무것도_승인하지_않는다(client, auth_headers, db_engine):
"""★ post_id 를 알아도 그 글 주인에게 연결된 발화자가 아니면 승인되지 않는다."""
_h1, pid1, _uid1 = await link(db_engine, client, auth_headers, "글주인-발화자", "글주인숙소")
await link(db_engine, client, auth_headers, "다른사람-발화자", "다른숙소")
post_id = await seed_post(db_engine, pid1)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "다른사람-발화자"))
assert res.status_code == 200
assert "올렸습니다" not in said(res)
assert await status_of(db_engine, post_id) == 3 # 그대로 SENT
assert await build_jobs(db_engine, pid1) == 0
async def test_연결되지_않은_발화자의_승인_버튼은_먹지_않는다(client, auth_headers, db_engine):
_h, pid, _uid = await link(db_engine, client, auth_headers, "연결된-사장님")
post_id = await seed_post(db_engine, pid)
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "모르는-클릭자"))
assert "올렸습니다" not in said(res)
assert await status_of(db_engine, post_id) == 3
assert await build_jobs(db_engine, pid) == 0
async def test_이미_승인한_글을_또_눌러도_두_번_올라가지_않는다(client, auth_headers, db_engine):
h, pid, uid = await link(db_engine, client, auth_headers, "두번-클릭자")
post_id = await seed_post(db_engine, pid)
first = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "두번-클릭자"))
second = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "두번-클릭자"))
assert "올렸습니다" in said(first)
assert "올렸습니다" not in said(second)
assert await build_jobs(db_engine, pid) == 1
async def test_기한이_지난_글의_승인_버튼은_먹지_않는다(client, auth_headers, db_engine):
from datetime import datetime, timedelta, timezone
h, pid, uid = await link(db_engine, client, auth_headers, "만료-클릭자")
post_id = await seed_post(db_engine, pid, expires=datetime.now(timezone.utc) - timedelta(hours=1))
res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "만료-클릭자"))
assert "올렸습니다" not in said(res)
assert await status_of(db_engine, post_id) == 3
async def test_승인_클릭은_모델을_부르지_않는다(client, auth_headers, db_engine, monkeypatch):
"""버튼 처리는 결정적이어야 한다 — 유료 호출도 없고, 모델이 다른 도구를 고를 여지도 없다."""
h, pid, uid = await link(db_engine, client, auth_headers, "모델없는-클릭자")
post_id = await seed_post(db_engine, pid)
called = AsyncMock()
monkeypatch.setattr(runtime, "chat", called)
await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(post_id, "모델없는-클릭자"))
called.assert_not_awaited()
async def test_모르는_종류나_이상한_글_id_의_클릭은_터지지_않는다(client, auth_headers, db_engine):
await link(db_engine, client, auth_headers, "이상한-클릭자")
bad_id = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=click_body("not-a-uuid", "이상한-클릭자"))
unknown = await client.post(
PATH, headers={"X-Agent-Secret": SECRET}, json=click_body(uuid.uuid4(), "이상한-클릭자", kind="delete")
)
assert bad_id.status_code == 200 and said(bad_id)
assert "올렸습니다" not in said(bad_id)
assert unknown.status_code == 200