[feat] solution/backend: 카톡 승인 알림에 [승인] 버튼 — 연결된 계정이 권한 (Event API 2-2)
2-1 로 카톡에 글과 [수정하기] 가 오게 됐고, 이제 카톡 안에서 바로 올린다.
승인 권한은 링크가 아니라 연결된 계정이다 — 버튼이 들고 온 글 ID 는 믿지 않는다.
- kakao_bot: 알림 카드에 [승인](action: block, blockId=KAKAO_APPROVE_BLOCK_ID,
extra={kind:approve, post_id}) 을 그리고, 클릭은 action.clientExtra 로 받는다.
블록 ID 가 비면 버튼을 그리지 않는다(눌러도 안 되는 버튼을 보내지 않는다)
- channel.approve_post: 누른 발화자 → 사장님 → 그 가게의 미처리·기한 전 글인지 재확인 후
approve_by_owner(메일·'바로 발행' 과 같은 경로라 재발행 잡·쓰레드 공유까지 동일).
연결 안 됨·남의 글·이미 올림·기한 지남은 같은 안내로 끝나고 두 번 올라가지 않는다.
답장은 "올렸습니다" + [사이트 보기](#blog). LLM 을 부르지 않는다
- 5초를 넘겨도 승인 작업은 취소하지 않는다(asyncio.shield) — 여러 번 커밋하는 작업이라
중간에 끊기면 승인만 되고 재발행이 안 걸린 글이 남는다
- PostService._blog_url → blog_url(채널이 [사이트 보기] 에 쓴다)
test_kakao_webhook 10건 추가(구현 전 5건 실패 확인), 관련 11개 스위트 239 passed.
실제 클릭 본문(action.clientExtra 위치)은 첫 클릭의 로그로 확인한다.
This commit is contained in:
parent
f1b473968d
commit
e0155d0423
@ -105,6 +105,9 @@ KAKAO_EVENT_DEV=0
|
||||
# 사장님 카톡에 빈 말풍선이 간다. 기본 꺼짐.
|
||||
KAKAO_APPROVAL_PUSH_ENABLED=0
|
||||
KAKAO_APPROVAL_EVENT_NAME=post_approval
|
||||
# [승인] 버튼이 부르는 블록 ID(오픈빌더 블록 주소의 /intent/<ID>). 그 블록에 우리 스킬이
|
||||
# 스킬데이터로 연결돼 있어야 한다. ★ 비우면 [승인] 버튼을 그리지 않는다(수정하기만 나간다).
|
||||
KAKAO_APPROVE_BLOCK_ID=
|
||||
KAKAO_CHANNEL_PUBLIC_ID=
|
||||
KAKAO_LINK_CODE_TTL_MIN=10
|
||||
KAKAO_LINK_MAX_ATTEMPTS=5
|
||||
|
||||
@ -441,7 +441,32 @@ channel.approval_notice 연결된 본인 가게의 글일 때만 본문 + [수
|
||||
2. 이벤트 블록에는 **필수 파라미터를 설정하지 않는다** — 설정하면 메시지가 발송되지 않는다.
|
||||
3. 배포.
|
||||
|
||||
### 다음(2-2)
|
||||
## 2-2 — [승인] 버튼 (2026-09-29)
|
||||
|
||||
[승인] 버튼 — action `block` 버튼의 `extra` 가 다음 스킬 요청의 `clientExtra` 로 돌아온다.
|
||||
승인은 링크가 아니라 연결된 계정이 권한이다(`PostService.approve_by_owner` 재사용).
|
||||
```
|
||||
알림 카드 [승인] (action: block, blockId=KAKAO_APPROVE_BLOCK_ID, extra={kind:approve, post_id})
|
||||
→ 그 블록의 스킬 요청 body.action.clientExtra 로 extra 도착
|
||||
→ kakao_bot._approve_click → channel.approve_post
|
||||
→ 발화자 키 → 사장님 → 그 가게의 미처리·기한 전 글인지 재확인 → PostService.approve_by_owner
|
||||
→ "올렸습니다" + [사이트 보기](#blog)
|
||||
```
|
||||
|
||||
- ★ **승인 권한은 링크가 아니라 연결된 계정이다.** extra 의 글 ID 는 믿지 않는다. 연결 안 된
|
||||
발화자·남의 글·이미 올린 글·기한 지난 글은 아무것도 승인하지 않고 같은 안내로 답한다.
|
||||
이미 올린 글을 또 눌러도 같은 관문에서 걸려 두 번 올라가지 않는다.
|
||||
- ★ `KAKAO_APPROVE_BLOCK_ID` 가 비면 [승인] 버튼을 그리지 않는다 — 콘솔 준비 전에 눌러도 안 되는
|
||||
버튼을 사장님 카톡에 보내지 않는다.
|
||||
- ★ 승인 처리는 LLM 을 부르지 않는다 — 결정적이어야 하고 유료 호출도 필요 없다.
|
||||
- ★ 5초를 넘겨도 **승인 작업을 취소하지 않는다**(`asyncio.shield`). 승인은 상태 변경 → 재발행 잡 →
|
||||
쓰레드 공유로 여러 번 커밋해서, 중간에 끊기면 승인만 되고 재발행이 안 걸린 글이 남는다.
|
||||
응답만 "승인하고 있어요" 로 먼저 돌려준다.
|
||||
- 로그에는 extra 의 **키만** 남긴다(`승인 클릭 — extra=[...]`) — 첫 실클릭에서 본문 모양을 확인한다.
|
||||
|
||||
### 콘솔
|
||||
|
||||
| 블록 | 이벤트 | 발화 | 필수 파라미터 | 봇 응답 |
|
||||
|---|---|---|---|---|
|
||||
| `미니 블로그 알림` | `post_approval` | 없음 | 없음 | 스킬데이터(web4ai-agent) |
|
||||
| `미니 블로그 승인` | **없음** | 없음 | 없음 | 스킬데이터(web4ai-agent) |
|
||||
|
||||
`미니 블로그 승인` 블록 ID(주소의 `/intent/<ID>`)를 `KAKAO_APPROVE_BLOCK_ID` 에 넣는다.
|
||||
|
||||
@ -129,7 +129,11 @@
|
||||
안 나갔으면 SENT 로 표시하지 않아 다음 스윕이 다시 시도한다. 글 본문과 [수정하기] 링크는
|
||||
오픈빌더 이벤트 블록의 스킬(우리 웹훅)이 그린다 — params 로 글 ID 와 수정용 일회용 코드가
|
||||
가고, 웹훅이 **연결된 본인 가게의 글인지 다시 확인한 뒤에만** 본문을 준다
|
||||
(`channel.approval_notice`). [승인] 버튼은 다음 단계다.
|
||||
(`channel.approval_notice`).
|
||||
- **카톡 [승인]**: 알림 카드의 [승인] 버튼(`KAKAO_APPROVE_BLOCK_ID` 가 있을 때만)을 누르면
|
||||
연결된 계정이 권한이 된다 — 버튼이 들고 온 글 ID 는 믿지 않고, 누른 발화자의 가게 글·미처리·
|
||||
기한 전인지 다시 본 뒤 메일·'바로 발행' 과 같은 `approve_by_owner` 로 올린다(재발행 잡 +
|
||||
쓰레드 공유까지 동일). 답장에 [사이트 보기](발행 사이트의 `#blog`)를 붙인다.
|
||||
|
||||
## 5. 승인·수정
|
||||
|
||||
|
||||
@ -54,6 +54,10 @@ class AgentConfig(BaseSettings):
|
||||
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=""):
|
||||
@ -72,6 +76,10 @@ 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"))
|
||||
|
||||
|
||||
@ -40,18 +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, links=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}}]}
|
||||
if links:
|
||||
# 링크 버튼은 카드에만 붙는다(simpleText 에는 버튼이 없다). 본문과 따로 둬 카드 설명 길이
|
||||
# 제한(글 문구가 그 안에 안 들어갈 수 있다)에 걸리지 않게 한다.
|
||||
payload["outputs"].append({"textCard": {
|
||||
"description": "고쳐서 올리려면 아래 버튼을 눌러 주세요.",
|
||||
"buttons": [{"label": link["label"], "action": "webLink", "webLinkUrl": link["url"]} for link in links[:3]],
|
||||
}})
|
||||
|
||||
# 버튼은 카드에만 붙는다(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 가 같은 문자열을 알고 있어야 먹는다.
|
||||
@ -61,6 +76,14 @@ def _reply(text: str, quick_replies=None, links=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:
|
||||
@ -88,7 +111,7 @@ 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"), answer.get("links"))
|
||||
return _reply_from(answer)
|
||||
|
||||
|
||||
async def _approval_notice(params: dict, speaker: str) -> dict:
|
||||
@ -107,7 +130,32 @@ async def _approval_notice(params: dict, speaker: str) -> 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"), answer.get("links"))
|
||||
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:
|
||||
@ -133,6 +181,13 @@ 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 가 있다.
|
||||
# 값(수정 코드는 비밀에 준한다)은 남기지 않고 어떤 키가 왔는지만 남긴다 — 실제 요청 본문이
|
||||
# 문서와 같은지를 눈으로 가릴 수 있게.
|
||||
|
||||
@ -31,6 +31,7 @@ from common.models.gmodel import PageParams
|
||||
from services.site_service import SiteService
|
||||
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
|
||||
|
||||
@ -61,9 +62,14 @@ def _now():
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
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 []}
|
||||
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:
|
||||
@ -346,7 +352,50 @@ async def approval_notice(channel_user_key: str, post_id: str, edit_token: str |
|
||||
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)
|
||||
# 승인 버튼은 글 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 = {
|
||||
|
||||
@ -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 —
|
||||
|
||||
@ -476,3 +476,172 @@ async def test_글_id_가_이상해도_터지지_않는다(client, auth_headers,
|
||||
|
||||
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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user