diff --git a/.env.example b/.env.example index e0c9cfa..2c4aa72 100644 --- a/.env.example +++ b/.env.example @@ -93,7 +93,21 @@ AGENT_CHAT_ENABLED=1 # 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))" KAKAO_WEBHOOK_SECRET= # 우리 봇이 맞는지 한 겹 더. 오발송을 거르는 용도라 비워도 된다. +# ★ 단, Event API(챗봇이 먼저 보내기)를 쓰려면 이 값이 필수다. 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/). 그 블록에 우리 스킬이 +# 스킬데이터로 연결돼 있어야 한다. ★ 비우면 [승인] 버튼을 그리지 않는다(수정하기만 나간다). +KAKAO_APPROVE_BLOCK_ID= KAKAO_CHANNEL_PUBLIC_ID= KAKAO_LINK_CODE_TTL_MIN=10 KAKAO_LINK_MAX_ATTEMPTS=5 diff --git a/docs/AGENT.md b/docs/AGENT.md index 5a28c3a..c6a2abf 100644 --- a/docs/AGENT.md +++ b/docs/AGENT.md @@ -395,6 +395,12 @@ URL 만 알면 누구나 이 엔드포인트를 때릴 수 있고, `userRequest. ★ **바로가기 라벨과 '예' 로 읽는 말이 같아야 한다**(`CONFIRM_LABEL` 등 상수). 어긋나면 눌러도 안 먹고, 사장님은 버튼이 고장난 줄 안다. +## 발행 요청 응답 (2026-09-29) + +발행 작업이 접수되면 **"발행을 시작했습니다. 완료 후 아래 주소에서 확인해 주세요."** 와 +해당 사이트의 URL을 함께 응답한다. 주소는 `site_payload.publish_url`로 구해 발행 주소와 +같은 규칙을 쓴다. 완료를 기다리거나 별도 완료 알림을 보내지 않는다 — 이 응답은 접수 안내다. + ## 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 +# 서버(도커)에서는: docker compose exec solution-backend python scripts/kakao_event_send_test.py --key +``` + +성공하면 카톡에 이벤트 블록의 말풍선이 뜬다. 요청은 성공인데 안 오면 이벤트 블록 연결 · +배포 · 채널 친구 여부 순서로 본다. + +## 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/`)를 `KAKAO_APPROVE_BLOCK_ID` 에 넣는다. diff --git a/docs/MINI_BLOG.md b/docs/MINI_BLOG.md index 180982c..740898e 100644 --- a/docs/MINI_BLOG.md +++ b/docs/MINI_BLOG.md @@ -122,6 +122,18 @@ 수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(day-pass 토큰도 `user_id` 까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `PostService.get_post` 가 `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. 승인·수정 diff --git a/solution/backend/config/agent_config.py b/solution/backend/config/agent_config.py index 5fd4107..4a4e611 100644 --- a/solution/backend/config/agent_config.py +++ b/solution/backend/config/agent_config.py @@ -38,8 +38,27 @@ class AgentConfig(BaseSettings): # 만드는 법: python -c "import secrets; print(secrets.token_urlsafe(32))" KAKAO_WEBHOOK_SECRET: str = "" # 우리 봇이 맞는지 한 겹 더 본다. 시크릿이 아니라 오발송을 거르는 용도라 비워도 된다. + # ★ Event API(챗봇이 먼저 보내기)는 이 값이 **필수**다 — 요청 주소에 봇 ID 가 들어간다. 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=""): return getattr(AgentConfig(), name, default) or default @@ -53,6 +72,14 @@ def webhook_secret() -> str: 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: return bool(get("KAKAO_CHANNEL_PUBLIC_ID")) diff --git a/solution/backend/router/v1/agent/kakao_bot.py b/solution/backend/router/v1/agent/kakao_bot.py index 458c0a2..51809b6 100644 --- a/solution/backend/router/v1/agent/kakao_bot.py +++ b/solution/backend/router/v1/agent/kakao_bot.py @@ -40,11 +40,33 @@ CALLBACK_DEADLINE_SEC = 45.0 _TIMEOUT_TEXT = "확인하는 데 시간이 조금 걸리네요. 잠시 뒤 다시 말씀해 주세요." _ERROR_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). ★ 카카오 형식을 아는 유일한 함수다.""" 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: # 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** — # channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다. @@ -54,6 +76,14 @@ def _reply(text: str, quick_replies=None) -> dict: 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: expected = config.webhook_secret() if not expected: @@ -81,7 +111,51 @@ async def _answer(utterance: str, speaker: str, deadline: float) -> dict: except Exception as ex: # noqa: BLE001 — 메신저에서는 500 도 침묵으로 보인다 LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}") 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: @@ -107,6 +181,21 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict: # 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다. 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초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다. callback_url = request.get("callbackUrl") # ★ "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. 어느 블록이 도는지도 같이 — diff --git a/solution/backend/scripts/kakao_event_send_test.py b/solution/backend/scripts/kakao_event_send_test.py new file mode 100644 index 0000000..6ea98b2 --- /dev/null +++ b/solution/backend/scripts/kakao_event_send_test.py @@ -0,0 +1,51 @@ +"""카카오 챗봇 Event API 테스트 발송 — 규격을 실제로 한 번 확인하는 1회성 스크립트. + + cd solution/backend && .venv/bin/python scripts/kakao_event_send_test.py --key + +★ 무엇을 확인하나: 콘솔 설정(채널 연결·월렛·이벤트 블록·배포)이 제대로 끝났는지, 그리고 + 요청/응답 모양이 문서와 같은지. 성공하면 카톡에 이벤트 블록의 말풍선이 뜬다. +★ 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))) diff --git a/solution/backend/services/agent/channel.py b/solution/backend/services/agent/channel.py index 67dbe41..2235adb 100644 --- a/solution/backend/services/agent/channel.py +++ b/solution/backend/services/agent/channel.py @@ -14,13 +14,14 @@ import re import uuid from datetime import datetime, timedelta, timezone +from urllib.parse import quote from sqlalchemy import select 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 users -from common.enums import DBWRType, ErrorType, KakaoLinkStatus +from common.database.model.models import place_posts, places, users +from common.enums import DBWRType, ErrorType, KakaoLinkStatus, PostStatus from common.models.gmodel import UserInfo from crud.place_crud import PlaceCRUD from crud.site_crud import SiteCRUD @@ -28,8 +29,9 @@ from crud.job_crud import JobQueue from common.enums import SiteStatus from common.models.gmodel import PageParams 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.post_service import PostService from services.agent.tools import REGISTRY from services.kakao_link_service import KakaoLinkError @@ -60,9 +62,14 @@ def _now(): return datetime.now(timezone.utc) -def _say(text: str, quick: list[str] | None = None) -> dict: - """채널이 모르는 모양으로 답한다 — 문구와 바로가기 목록뿐이다.""" - return {"text": text, "quick_replies": quick or []} +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: + """채널이 모르는 모양으로 답한다 — 문구, 바로가기 목록, 링크 버튼({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: @@ -313,6 +320,117 @@ async def handle(utterance: str, channel_user_key: str) -> dict: 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 = { "PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.", "AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.", diff --git a/solution/backend/services/blog_jobs.py b/solution/backend/services/blog_jobs.py index b439ff7..df72dda 100644 --- a/solution/backend/services/blog_jobs.py +++ b/solution/backend/services/blog_jobs.py @@ -11,15 +11,17 @@ import uuid from datetime import date, datetime, timedelta, timezone +import httpx 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.model.models import place_posts, places, sites, users from common.enums import DBWRType, PostStatus, SiteStatus from common.logger import LOG 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 BATCH_SIZE = 30 @@ -225,29 +227,90 @@ def _app_origin() -> str: 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: - """토큰 발급 → 메일 본문 조립 → 발송 → 성공하면 SENT 로 표시. 실패하면 DB 를 안 건드린다.""" + """토큰 발급 → 메일·카톡 발송 → 하나라도 나갔으면 SENT 로 표시. 아무 데도 안 나갔으면 + DB 를 안 건드린다(다시 시도할 길을 막지 않는다). + + ★ 카톡과 메일은 **둘 다** 보낸다(2026-09-29 결정). 카톡은 채널 친구가 아니거나 차단했으면 + 실패하므로, 카톡이 붙어도 메일을 빼지 않는다.""" token, token_hash, expires = blog_service.issue_token() # ★ 수정 링크도 일회용 코드다. 예전에는 여기 빌더 액세스 토큰을 통짜로 실었고, # 메일 전달 한 번이 자정까지의 권한 양도였다(migrations/0023). edit_token, edit_hash, _edit_expires = blog_service.issue_token() - body = _mail_body( - place_name=place.name, post=post, user=user, - origin=_app_origin(), approve_token=token, edit_token=edit_token, - ) - ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body) - if not ok: - return False - await DB_SESSION_MNG.execute_lambda_run( - [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)], - ) - return True + + mail_ok = False + if _can_mail(place, user): + body = _mail_body( + place_name=place.name, post=post, user=user, + origin=_app_origin(), approve_token=token, edit_token=edit_token, + ) + mail_ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body) + + async def mark_sent(): + await DB_SESSION_MNG.execute_lambda_run( + [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: """검수를 통과한 글을 사장님에게 한 통씩 보낸다. 보낸 수를 돌려준다.""" - if not mail_service.is_configured(): + if not mail_service.is_configured() and not _kakao_push_ready(): return 0 _err, rows = await DB_SESSION_MNG.execute_lambda( @@ -264,7 +327,8 @@ async def send_reviewed() -> int: if not target: continue 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 if await _send_one(place, user, post): sent += 1 @@ -275,7 +339,7 @@ async def send_now_for_place(place_id: str) -> dict: """사장님이 빌더 화면에서 누르는 즉시 발송 — 아침 9시 스윕을 기다리지 않고 이 업장의 오늘 몫을 지금 보낸다(2026-09-21, 사장님 요청: "지금 바로 발송할 수 있도록"). '하루 한 통' 원칙은 그대로다 — 이미 오늘 보냈으면(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"} place = user = None @@ -293,7 +357,7 @@ async def send_now_for_place(place_id: str) -> dict: if post is None: 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"} if not await _send_one(place, user, post): diff --git a/solution/backend/services/external/kakao_event.py b/solution/backend/services/external/kakao_event.py new file mode 100644 index 0000000..73b4032 --- /dev/null +++ b/solution/backend/services/external/kakao_event.py @@ -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 "") diff --git a/solution/backend/services/kakao_link_service.py b/solution/backend/services/kakao_link_service.py index 211343c..7e5f99c 100644 --- a/solution/backend/services/kakao_link_service.py +++ b/solution/backend/services/kakao_link_service.py @@ -179,6 +179,28 @@ async def resolve(channel_user_key: str) -> UUID | None: 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: """연결을 끊는다. 행은 REVOKED 로 남긴다 — 지우면 누가 언제 연결했는지가 사라진다. diff --git a/solution/backend/services/post_service.py b/solution/backend/services/post_service.py index a6a0941..973bd88 100644 --- a/solution/backend/services/post_service.py +++ b/solution/backend/services/post_service.py @@ -135,10 +135,10 @@ class PostService: return { "success": True, "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, 사장님 지시) — 사장님이 승인만 하고 실제로 어디에 올라갔는지 못 찾는 걸 줄인다. 사이트가 없거나 아직 미발행이면 None — diff --git a/solution/backend/tests/test_blog_owner.py b/solution/backend/tests/test_blog_owner.py index 14e9e63..85c9c4c 100644 --- a/solution/backend/tests/test_blog_owner.py +++ b/solution/backend/tests/test_blog_owner.py @@ -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 +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: """메일의 '수정하려면' 링크가 주는 것과 같은 종류의 day-pass 토큰(2026-09-21, 사장님 지시: "메일 링크에서 들어와 수정한 후에는 승인할 수 있어야 한다").""" diff --git a/solution/backend/tests/test_kakao_event.py b/solution/backend/tests/test_kakao_event.py new file mode 100644 index 0000000..7c7bc39 --- /dev/null +++ b/solution/backend/tests/test_kakao_event.py @@ -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) diff --git a/solution/backend/tests/test_kakao_webhook.py b/solution/backend/tests/test_kakao_webhook.py index 9ed6a2b..e7c0c23 100644 --- a/solution/backend/tests/test_kakao_webhook.py +++ b/solution/backend/tests/test_kakao_webhook.py @@ -425,3 +425,277 @@ async def test_확인을_눌렀는데_가게가_없으면_그렇다고_말한다 ) res = await client.post(PATH, headers={"X-Agent-Secret": SECRET}, json=body("네", speaker)) 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