[feat] solution/backend: 승인 알림을 카톡으로도 발송하고 웹훅이 메시지를 그린다 (Event API 2-1)

결정(2026-09-29): 카톡과 메일 둘 다, 승인은 링크가 아니라 연결된 계정 신원으로.
이 커밋은 발송과 메시지 그리기까지고 [승인] 버튼은 다음 단계(2-2)다.

- blog_jobs._send_one: 메일에 더해 Event API 로 보낸다. 하나라도 나가면 SENT,
  아무 데도 안 나가면 SENT 로 표시하지 않아 다음 스윕이 다시 시도한다. 카톡은 채널
  친구가 아니거나 차단했으면 실패하므로 메일을 빼지 않는다. send_now 도 같은 경로
- 카톡 params 로 post_id 와 수정용 일회용 코드(edit_token)를 넘긴다. 코드 평문은
  발송 시점에만 알아서다. 로그에는 params 의 키만 남기고 값은 남기지 않는다
- channel.approval_notice: 발화자 키 → 사장님 → 그 글이 그 사장님 가게 것·미처리·
  기한 전일 때만 본문과 [수정하기] 를 준다. 연결 안 됨·남의 글·처리됨·만료·이상한
  ID 는 구분 없이 같은 안내(구분해 주면 글 ID 를 탐색할 수 있다)
- kakao_bot: userRequest.params.post_id 가 있으면 승인 알림 요청으로 처리하고 링크
  버튼은 본문과 따로 textCard 로 그린다(카드 설명 길이 제한을 피한다)
- KAKAO_APPROVAL_PUSH_ENABLED(기본 0), KAKAO_APPROVAL_EVENT_NAME 추가 —
  오픈빌더 이벤트 블록(스킬 연결)과 배포가 끝나기 전에는 켜지 않는다

test_blog_owner 6건·test_kakao_webhook 6건 추가, 카카오·미니블로그 스위트 119 passed,
인접 스위트 110 passed. 실제 카톡 수신은 콘솔 설정·운영 배포 뒤에 확인한다.
This commit is contained in:
김성경 2026-09-29 16:13:07 +09:00
parent 983c05b3c3
commit f1b473968d
10 changed files with 519 additions and 27 deletions

View File

@ -100,6 +100,11 @@ KAKAO_BOT_ID=
KAKAO_BOT_REST_API_KEY= KAKAO_BOT_REST_API_KEY=
# 1 이면 개발 채널로 보낸다(봇 ID 뒤에 "!"). 운영 채널이면 0. # 1 이면 개발 채널로 보낸다(봇 ID 뒤에 "!"). 운영 채널이면 0.
KAKAO_EVENT_DEV=0 KAKAO_EVENT_DEV=0
# 미니블로그 승인 알림을 메일에 더해 연결된 카톡으로도 보낸다(Event API).
# ★ 오픈빌더에 이벤트 블록(스킬 연결)을 만들고 배포하기 전에는 켜지 않는다 — 켜면
# 사장님 카톡에 빈 말풍선이 간다. 기본 꺼짐.
KAKAO_APPROVAL_PUSH_ENABLED=0
KAKAO_APPROVAL_EVENT_NAME=post_approval
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

@ -413,3 +413,35 @@ cd solution/backend && .venv/bin/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)
[승인] 버튼 — action `block` 버튼의 `extra` 가 다음 스킬 요청의 `clientExtra` 로 돌아온다.
승인은 링크가 아니라 연결된 계정이 권한이다(`PostService.approve_by_owner` 재사용).

View File

@ -122,6 +122,14 @@
수정 링크로 할 수 있는 일은 **그 글 한 건의 편집·승인**뿐이다(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`). [승인] 버튼은 다음 단계다.
## 5. 승인·수정 ## 5. 승인·수정

View File

@ -49,6 +49,11 @@ class AgentConfig(BaseSettings):
# "1" 이면 봇 ID 뒤에 "!" 를 붙여 **개발 채널**로 보낸다(운영 채널과 요청 주소가 다르다). # "1" 이면 봇 ID 뒤에 "!" 를 붙여 **개발 채널**로 보낸다(운영 채널과 요청 주소가 다르다).
# ★ KAKAO_BOT_ID 자체를 고쳐 쓰지 않는다 — 웹훅이 그 값으로 요청의 bot.id 를 대조한다. # ★ KAKAO_BOT_ID 자체를 고쳐 쓰지 않는다 — 웹훅이 그 값으로 요청의 bot.id 를 대조한다.
KAKAO_EVENT_DEV: str = "0" KAKAO_EVENT_DEV: str = "0"
# 미니블로그 승인 알림을 메일에 더해 카톡으로도 보낸다. ★ 기본 꺼짐 — 오픈빌더에 이벤트
# 블록(스킬 연결)과 배포가 끝나기 전에 켜면 사장님 카톡에 빈 말풍선이 간다.
KAKAO_APPROVAL_PUSH_ENABLED: str = "0"
# 오픈빌더에 정의한 이벤트 이름. 말풍선을 그리는 블록이 이 이름에 걸려 있다.
KAKAO_APPROVAL_EVENT_NAME: str = "post_approval"
def get(name, default=""): def get(name, default=""):
@ -63,6 +68,10 @@ 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 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

@ -42,9 +42,16 @@ _ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해
_WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요." _WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요."
def _reply(text: str, quick_replies=None) -> dict: def _reply(text: str, quick_replies=None, links=None) -> dict:
"""오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다.""" """오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다."""
payload: dict = {"outputs": [{"simpleText": {"text": text}}]} payload: dict = {"outputs": [{"simpleText": {"text": text}}]}
if links:
# 링크 버튼은 카드에만 붙는다(simpleText 에는 버튼이 없다). 본문과 따로 둬 카드 설명 길이
# 제한(글 문구가 그 안에 안 들어갈 수 있다)에 걸리지 않게 한다.
payload["outputs"].append({"textCard": {
"description": "고쳐서 올리려면 아래 버튼을 눌러 주세요.",
"buttons": [{"label": link["label"], "action": "webLink", "webLinkUrl": link["url"]} for link in links[:3]],
}})
if quick_replies: if quick_replies:
# 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** — # 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** —
# channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다. # channel.py 의 _YES/_NO 가 같은 문자열을 알고 있어야 먹는다.
@ -81,7 +88,26 @@ 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(answer["text"], answer.get("quick_replies"), answer.get("links"))
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(answer["text"], answer.get("quick_replies"), answer.get("links"))
async def _push(callback_url: str, utterance: str, speaker: str) -> None: async def _push(callback_url: str, utterance: str, speaker: str) -> None:
@ -107,6 +133,14 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
# 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다. # 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다.
return _reply("사용자를 확인하지 못했어요.") return _reply("사용자를 확인하지 못했어요.")
# ★ 우리가 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

@ -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,7 +29,7 @@ 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.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 +61,9 @@ 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) -> dict:
"""채널이 모르는 모양으로 답한다 — 문구와 바로가기 목록뿐이다.""" """채널이 모르는 모양으로 답한다 — 문구, 바로가기 목록, 링크 버튼({label, url})뿐이다."""
return {"text": text, "quick_replies": quick or []} return {"text": text, "quick_replies": quick or [], "links": links or []}
async def _user_info(user_id) -> UserInfo | None: async def _user_info(user_id) -> UserInfo | None:
@ -280,6 +281,74 @@ 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='')}"}]
return _say(f"[{name}] 이번 글을 올릴까요?\n\n{post.body}", links=links)
_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()
mail_ok = False
if _can_mail(place, user):
body = _mail_body( body = _mail_body(
place_name=place.name, post=post, user=user, place_name=place.name, post=post, user=user,
origin=_app_origin(), approve_token=token, edit_token=edit_token, 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) mail_ok = mail_service.send(to=_notify_address(place, user), subject=f"[{place.name}] 이번 글 올릴까요?", text=body)
if not ok:
return False async def mark_sent():
await DB_SESSION_MNG.execute_lambda_run( await DB_SESSION_MNG.execute_lambda_run(
[place_posts.DBType()], [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)], [lambda s, pid=post.post_id, h=token_hash, e=expires, eh=edit_hash: _crud.mark_sent(s, pid, h, e, eh)],
) )
return True
# ★ 메일이 나갔으면 카톡보다 **먼저** 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

@ -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

@ -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

@ -371,3 +371,108 @@ async def test_콜백_전송이_실패해도_터지지_않는다(monkeypatch):
monkeypatch.setattr(bot.httpx, "AsyncClient", lambda **_kw: Boom()) monkeypatch.setattr(bot.httpx, "AsyncClient", lambda **_kw: Boom())
await bot._push("https://callback.example/x", "안녕", "누구") # 예외가 새 나오지 않는다 await bot._push("https://callback.example/x", "안녕", "누구") # 예외가 새 나오지 않는다
# ── 승인 알림(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)