[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=
# 1 이면 개발 채널로 보낸다(봇 ID 뒤에 "!"). 운영 채널이면 0.
KAKAO_EVENT_DEV=0
# 미니블로그 승인 알림을 메일에 더해 연결된 카톡으로도 보낸다(Event API).
# ★ 오픈빌더에 이벤트 블록(스킬 연결)을 만들고 배포하기 전에는 켜지 않는다 — 켜면
# 사장님 카톡에 빈 말풍선이 간다. 기본 꺼짐.
KAKAO_APPROVAL_PUSH_ENABLED=0
KAKAO_APPROVAL_EVENT_NAME=post_approval
KAKAO_CHANNEL_PUBLIC_ID=
KAKAO_LINK_CODE_TTL_MIN=10
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`
까지만 담아, 그 사장님의 다른 글은 못 건드리지 않는다 — `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`). [승인] 버튼은 다음 단계다.
## 5. 승인·수정

View File

@ -49,6 +49,11 @@ class AgentConfig(BaseSettings):
# "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"
def get(name, default=""):
@ -63,6 +68,10 @@ def webhook_secret() -> str:
return get("KAKAO_WEBHOOK_SECRET")
def approval_push_enabled() -> bool:
return get("KAKAO_APPROVAL_PUSH_ENABLED", "0") == "1"
def kakao_link_enabled() -> bool:
return bool(get("KAKAO_CHANNEL_PUBLIC_ID"))

View File

@ -42,9 +42,16 @@ _ERROR_TEXT = "지금은 처리할 수 없어요. 잠시 뒤 다시 말씀해
_WAIT_TEXT = "확인하고 있어요. 잠시만 기다려 주세요."
def _reply(text: str, quick_replies=None) -> dict:
def _reply(text: str, quick_replies=None, links=None) -> dict:
"""오픈빌더 스킬 응답(SkillResponse). ★ 카카오 형식을 아는 유일한 함수다."""
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:
# 바로가기는 최대 10개. 누르면 그 라벨이 **다음 발화로 그대로 들어온다** —
# 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 도 침묵으로 보인다
LOG.w(f"[agent/kakao] 처리 실패: {type(ex).__name__}")
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:
@ -107,6 +133,14 @@ async def _handle(body: dict, tasks: BackgroundTasks) -> dict:
# 발화자를 모르면 누구의 가게인지도 모른다. 여기서 끝낸다.
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초 벽을 넘을 수 있다. 즉답하고 뒤에서 마저 만든다.
callback_url = request.get("callbackUrl")
# ★ "콜백을 켰는데 왜 안 되나" 를 눈으로 가릴 수 있게 남긴다. 어느 블록이 도는지도 같이 —

View File

@ -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,7 +29,7 @@ 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.agent.tools import REGISTRY
from services.kakao_link_service import KakaoLinkError
@ -60,9 +61,9 @@ 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) -> dict:
"""채널이 모르는 모양으로 답한다 — 문구, 바로가기 목록, 링크 버튼({label, url})뿐이다."""
return {"text": text, "quick_replies": quick or [], "links": links or []}
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)
# 승인 알림에서 보여줄 수 있는 글 = 아직 사이트에 안 올린 글. 메일 승인 링크와 같은 범위다
# (검수 통과 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 = {
"PLACE_NOT_FOUND": "그 가게를 찾지 못했어요.",
"AGENT_NOT_CONFIGURED": "지금은 대화 기능이 꺼져 있어요.",

View File

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

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)
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 로 남긴다 — 지우면 누가 언제 연결했는지가 사라진다.

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
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,
사장님 지시: "메일 링크에서 들어와 수정한 후에는 승인할 수 있어야 한다")."""

View File

@ -371,3 +371,108 @@ async def test_콜백_전송이_실패해도_터지지_않는다(monkeypatch):
monkeypatch.setattr(bot.httpx, "AsyncClient", lambda **_kw: Boom())
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)