Merge pull request #249 from NomaDamas/feature/#248

Feature/#248
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-15 18:02:04 +09:00 committed by GitHub
commit 5a6dcedb99
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 595 additions and 9 deletions

View file

@ -27,7 +27,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
| 고속버스 예매 | `express-bus-booking` | KOBUS 고속버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [고속버스 예매 가이드](docs/features/express-bus-booking.md) |
| 시외버스 예매 | `intercity-bus-booking` | 티머니 시외버스 배차·좌석·요금 조회와 결제 직전 handoff(결제는 수동) | 불필요 | [시외버스 예매 가이드](docs/features/intercity-bus-booking.md) |
| 자연휴양림 빈 객실 조회 | `foresttrip-vacancy` | 공식 숲나들e 자연휴양림 예약 가능 객실 조회 자동화 (예약/결제 제외) | 필요 | [자연휴양림 빈 객실 조회 가이드](docs/features/foresttrip-vacancy.md) |
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송 | 불필요 | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 카카오톡 Mac CLI | `kakaotalk-mac` | macOS에서 카카오톡 대화 조회, 검색, 메시지 전송/삭제 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac CLI 가이드](docs/features/kakaotalk-mac.md) |
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
| 한국 대중교통 길찾기 | `korean-transit-route` | ODsay LIVE API + Kakao geocoding 기반 출발지→도착지 지하철+버스+도보 경로 및 환승 정보 조회 | 필요 | [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md) |

View file

@ -7,6 +7,7 @@
- 키워드로 전체 대화 검색
- 나와의 채팅으로 안전하게 테스트 전송
- 사용자 확인 후 특정 채팅방으로 메시지 전송
- 로컬 메시지 ID로 후보를 고른 뒤 현재 보이는 정확히 하나의 UI bubble 삭제 자동화
## 먼저 필요한 것
@ -32,6 +33,7 @@ mas install 869223134
- 검색 키워드
- 최근 범위(`--since 1h`, `--since 7d` 등)
- 전송 메시지 본문
- 삭제할 메시지 ID 또는 최근 보낸 메시지 삭제 여부
- 테스트 여부(`--me`, `--dry-run`)
## 기본 흐름
@ -41,7 +43,8 @@ mas install 869223134
3. `user_id` 자동 감지가 실패하면 helper `python3 scripts/kakaotalk_mac.py auth --refresh` 로 복구한다.
4. 읽기/검색은 JSON 모드로 실행한 뒤 사람이 읽기 쉽게 요약한다.
5. 전송은 먼저 `--me` 또는 `--dry-run` 으로 테스트한다.
6. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
6. 삭제는 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 을 먼저 실행한다.
7. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
## 예시
@ -57,6 +60,9 @@ kakaocli messages --chat "지수" --since 1d --json
kakaocli search "회의" --json
kakaocli send --me _ "테스트 메시지"
kakaocli send --dry-run "팀 공지방" "오늘 3시에 만나요"
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --dry-run
```
## helper 가 해결하는 문제
@ -74,11 +80,27 @@ helper `scripts/kakaotalk_mac.py` 는 그 얇은 read-only 어댑터 역할을
- 검증된 DB 경로와 SQLCipher key 를 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
- 이후 read-only helper 명령 `chats`, `messages`, `search`, `schema` 를 cached `--db` / `--key` 와 함께 다시 실행한다.
## 메시지 삭제
`delete` / `delete-last` 는 카카오톡 Mac UI의 삭제 메뉴를 Accessibility 로 자동화한다. 메시지 조회와 outbound 여부 검증은 로컬 DB에서 하고, 실제 삭제는 현재 보이는 UI bubble 에서 수행한다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "팀 공지방" --limit 20 --json
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "팀 공지방" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --everyone
```
- `--everyone` 은 내가 보낸 메시지에만 허용된다.
- UI 삭제 단계는 활성 채팅방을 확인하고, 선택된 outbound DB 메시지의 정규화된 텍스트가 대화 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 진행한다. 로컬 DB message id가 UI bubble identity를 직접 증명하는 것은 아니므로, 메시지 텍스트가 비어 있거나 첨부/비텍스트이거나 보이지 않거나 정규화 후 같은 텍스트가 여러 개이거나 최종 확인 버튼을 클릭할 수 없으면 실패한다.
- `chats`, `messages`, `search`, `schema` 는 read-only 이지만 `delete` / `delete-last` 는 side effect 이다.
## 주의할 점
- **Full Disk Access** 가 없으면 읽기 명령도 실패할 수 있다.
- **Accessibility** 가 없으면 전송과 harvest 계열 자동화가 실패한다.
- **Accessibility** 가 없으면 전송, 삭제, harvest 계열 자동화가 실패한다.
- macOS 전용이므로 Windows/Linux 대체 구현으로 넘어가지 않는다.
- 다른 사람에게 보내는 메시지는 자동 전송하지 말고 확인을 먼저 받는다.
- 삭제 자동화는 되돌리기 어렵기 때문에 `--dry-run` 으로 대상과 범위를 확인한다.
- helper cache 는 로컬 auth material 을 담으므로 본인 장비에서만 보관한다.
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.

View file

@ -1,6 +1,6 @@
---
name: kakaotalk-mac
description: Use kakaocli on macOS to read KakaoTalk chats, search messages, and send replies after explicit confirmation.
description: Use kakaocli on macOS to read KakaoTalk chats, search messages, send replies after explicit confirmation, and delete sent messages with explicit operator intent.
license: MIT
metadata:
category: messaging
@ -12,7 +12,7 @@ metadata:
## What this skill does
`kakaocli` 를 사용해 macOS에서 카카오톡 대화 목록을 확인하고, 메시지를 검색하고, 필요할 때 답장을 보낸다.
`kakaocli` 와 이 저장소 helper를 사용해 macOS에서 카카오톡 대화 목록을 확인하고, 메시지를 검색하고, 필요할 때 답장하거나 보낸 메시지를 삭제한다.
이 스킬은 **macOS + 카카오톡 Mac 앱 설치**를 전제로 한다. 공식 Kakao API를 쓰는 것이 아니라 로컬 데이터베이스 읽기와 macOS 접근성 자동화 위에서 동작하므로, 권한과 안전 규칙을 먼저 확인해야 한다.
@ -47,6 +47,7 @@ metadata:
- 채팅방 이름 또는 검색 키워드
- 읽기 범위: 최근 N개, `--since 1h`, `--since 7d`
- 전송할 메시지 본문
- 삭제할 로컬 메시지 ID 또는 최근 보낸 메시지 삭제 여부
- 테스트 여부 (`--me`, `--dry-run`)
## Workflow
@ -87,7 +88,7 @@ kakaocli status
기본 규칙:
- `status` / `auth` / `chats` 같은 읽기 명령도 Full Disk Access 가 필요하다.
- `send`, `harvest`, `inspect` 류 작업은 Accessibility 권한까지 필요하다.
- `send`, `delete`, `delete-last`, `harvest`, `inspect` 류 작업은 Accessibility 권한까지 필요하다.
### 3. Verify read access before attempting side effects
@ -166,7 +167,25 @@ kakaocli send --dry-run "채팅방 이름" "보낼 문장"
kakaocli send "채팅방 이름" "보낼 문장"
```
### 7. Use login storage only when the user explicitly wants auto-login
### 7. Delete a sent message only with explicit operator intent
삭제는 카카오톡 Mac UI(우클릭 → 삭제)를 접근성으로 자동화한다. 먼저 `messages --json` 으로 로컬 메시지 ID와 보낸 메시지 여부를 확인하고, 항상 `--dry-run` 으로 대상 채팅방/메시지를 확인한 뒤 실행한다. `--everyone` 은 내가 보낸 메시지에만 허용된다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "채팅방 이름" --limit 20 --json
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --dry-run
python3 scripts/kakaotalk_mac.py delete "채팅방 이름" 123456 --everyone
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --dry-run
python3 scripts/kakaotalk_mac.py delete-last "채팅방 이름" --everyone
```
주의:
- helper의 `chats`, `messages`, `search`, `schema` 는 read-only 경로다. `delete` / `delete-last` 는 UI side effect 이므로 Accessibility 권한과 명시적 실행 의도가 필요하다.
- 메시지 ID는 로컬 DB의 `messages --json` 출력 기준이며 UI에서 동일한 DB row를 직접 증명할 수 있다는 뜻은 아니다. 실행 계약은 선택된 outbound DB 메시지의 정규화된 텍스트가 현재 활성 채팅방 transcript 영역에서 정확히 하나의 visible targetable message bubble 과 일치할 때만 삭제하는 것이다.
- 대상 메시지 텍스트가 비어 있거나 첨부/비텍스트 메시지이거나, 정규화 후 같은 텍스트가 여러 개 있거나, 대상 bubble 이 보이지 않거나, 활성 채팅방/삭제 범위/최종 확인 버튼을 확인할 수 없으면 삭제 자동화는 실패한다.
### 8. Use login storage only when the user explicitly wants auto-login
자동 로그인 편의를 원할 때만 자격증명을 저장한다.
@ -182,6 +201,7 @@ kakaocli login --status
- 읽기 요청이면 상태 확인 + 대화/메시지 조회 결과가 정리되어 있다
- 검색 요청이면 키워드 기준 결과가 정리되어 있다
- 전송 요청이면 테스트(`--me` 또는 `--dry-run`)와 사용자 확인이 끝난 뒤 실제 전송 여부가 명확하다
- 삭제 요청이면 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 검증 뒤 실행 결과가 명확하다
## Failure modes
@ -196,6 +216,7 @@ kakaocli login --status
- 이 스킬은 macOS 전용이다.
- 다른 사람에게 보내는 메시지는 항상 confirm before sending 원칙을 지킨다.
- 삭제는 되돌리기 어렵고 UI 상태에 민감하므로 먼저 `--dry-run` 으로 대상과 범위를 확인한다.
- 첫 검증은 `kakaocli status``kakaocli auth` 부터 시작하는 편이 안전하다.
- `kakaocli auth``User ID: auto-detection failed` 로 멈추면 helper 경로를 우선 사용한다.
- helper cache 는 로컬 SQLCipher key 를 포함하므로 본인 계정에서만 유지하고 공유하지 않는다.

View file

@ -11,6 +11,7 @@ import re
import subprocess
import sys
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Callable, Iterable, Sequence
@ -49,6 +50,14 @@ class ResolvedAuth:
source: str
@dataclass
class DeleteTarget:
message_id: int
text: str
timestamp: str | None
is_from_me: bool
def parse_plist_xml(xml_text: str) -> Any:
tokens = tokenize_plist_xml(xml_text)
if not tokens:
@ -575,6 +584,317 @@ def build_passthrough_command(command: str, auth: ResolvedAuth, forwarded_args:
]
def load_messages_for_delete(chat: str, auth: ResolvedAuth, *, limit: int) -> list[dict[str, Any]]:
result = run_command(
build_passthrough_command("messages", auth, ["--chat", chat, "--limit", str(limit), "--json"]),
check=True,
)
try:
payload = json.loads(result.stdout)
except json.JSONDecodeError as exc:
raise AuthResolutionError(f"Could not parse kakaocli messages JSON: {exc}") from exc
if not isinstance(payload, list):
raise AuthResolutionError("kakaocli messages --json did not return a JSON array")
return [item for item in payload if isinstance(item, dict)]
def select_delete_target(
messages: Sequence[dict[str, Any]],
*,
message_id: int | None,
delete_last: bool,
everyone: bool,
) -> DeleteTarget:
if delete_last:
candidates = [message for message in messages if bool(message.get("is_from_me"))]
if not candidates:
raise AuthResolutionError("No outbound messages were found for delete-last.")
raw = max(candidates, key=_delete_last_sort_key)
else:
if message_id is None:
raise AuthResolutionError("message_id is required for delete.")
raw = next((message for message in messages if _message_id(message) == message_id), None)
if raw is None:
raise AuthResolutionError(f"Message id {message_id} was not found in the fetched chat history.")
selected_id = _message_id(raw)
if selected_id is None:
raise AuthResolutionError("Selected message is missing an id.")
is_from_me = bool(raw.get("is_from_me"))
if not is_from_me:
raise AuthResolutionError(
"Delete automation only supports messages sent by this KakaoTalk account; "
"--everyone also requires an outbound message."
)
text = raw.get("text")
normalized_text = _normalize_delete_text(text)
if normalized_text is None:
raise AuthResolutionError(
"Delete automation requires a selected outbound message with non-empty text; "
"non-text, attachment, or empty-text messages are not safe UI delete targets."
)
matching_text_ids = [
_message_id(message)
for message in messages
if _normalize_delete_text(message.get("text")) == normalized_text
]
if len([item for item in matching_text_ids if item is not None]) > 1:
raise AuthResolutionError(
"Refusing to automate deletion because multiple fetched messages have the same normalized visible text. "
"Open the chat with only the target visible or use delete-last for the latest outbound message."
)
text = normalized_text
timestamp = raw.get("timestamp")
return DeleteTarget(
message_id=selected_id,
text=text,
timestamp=str(timestamp) if timestamp is not None else None,
is_from_me=is_from_me,
)
def _normalize_delete_text(value: Any) -> str | None:
if not isinstance(value, str):
return None
normalized = " ".join(value.split())
return normalized or None
def _message_id(message: dict[str, Any]) -> int | None:
value = message.get("id")
if isinstance(value, bool):
return None
if isinstance(value, int):
return value
if isinstance(value, str) and value.isdigit():
return int(value)
return None
def _delete_last_sort_key(message: dict[str, Any]) -> tuple[float, int]:
timestamp_score = _timestamp_sort_score(message.get("timestamp"))
message_id = _message_id(message) or 0
return (timestamp_score, message_id)
def _timestamp_sort_score(value: Any) -> float:
if isinstance(value, bool) or value is None:
return 0.0
if isinstance(value, (int, float)):
return float(value)
if not isinstance(value, str):
return 0.0
normalized = value.strip()
if not normalized:
return 0.0
if normalized.isdigit():
return float(normalized)
try:
if normalized.endswith("Z"):
normalized = f"{normalized[:-1]}+00:00"
parsed = datetime.fromisoformat(normalized)
if parsed.tzinfo is None:
parsed = parsed.replace(tzinfo=timezone.utc)
return parsed.timestamp()
except ValueError:
return 0.0
def build_delete_osascript(chat: str, target: DeleteTarget, *, everyone: bool) -> str:
scope_labels = (
["모두에게서 삭제", "Delete for Everyone", "Delete for everyone"]
if everyone
else ["나에게서만 삭제", "Delete for Me", "Delete for me", "삭제", "Delete"]
)
labels = ", ".join(_applescript_string(label) for label in scope_labels)
return f"""
on normalizeText(rawText)
set normalizedText to rawText as text
set normalizedText to do shell script "python3 -c 'import sys; print(\\" \\".join(sys.stdin.read().split()))'" with input normalizedText
return normalizedText
end normalizeText
set chatName to {_applescript_string(chat)}
set messageText to {_applescript_string(target.text)}
set normalizedChatName to normalizeText(chatName)
set normalizedMessageText to normalizeText(messageText)
set deleteLabels to {{{labels}}}
tell application "KakaoTalk" to activate
delay 0.5
tell application "System Events"
tell process "KakaoTalk"
set frontmost to true
keystroke "f" using command down
delay 0.2
keystroke chatName
delay 0.2
key code 36
delay 0.8
key code 36
delay 1.0
set activeChatMatches to {{}}
try
set frontWindowName to name of front window as text
if normalizeText(frontWindowName) is normalizedChatName then set end of activeChatMatches to front window
end try
try
repeat with chatCandidate in static texts of front window
try
set chatCandidateValue to value of chatCandidate as text
if normalizeText(chatCandidateValue) is normalizedChatName then set end of activeChatMatches to chatCandidate
end try
end repeat
end try
try
repeat with headerGroup in groups of front window
try
repeat with chatCandidate in static texts of headerGroup
try
set chatCandidateValue to value of chatCandidate as text
if normalizeText(chatCandidateValue) is normalizedChatName then set end of activeChatMatches to chatCandidate
end try
end repeat
end try
end repeat
end try
if (count of activeChatMatches) is 0 then error "Could not verify the active KakaoTalk chat."
set messageListCandidates to {{}}
try
repeat with scrollArea in scroll areas of front window
try
if (count of static texts of scrollArea) is greater than 0 then set end of messageListCandidates to scrollArea
end try
try
repeat with messageGroup in groups of scrollArea
try
if (count of static texts of messageGroup) is greater than 0 then set end of messageListCandidates to messageGroup
end try
end repeat
end try
end repeat
end try
if (count of messageListCandidates) is 0 then error "Could not find the KakaoTalk message transcript area."
set matchingElements to {{}}
repeat with messageListCandidate in messageListCandidates
try
repeat with candidate in static texts of messageListCandidate
try
set candidateValue to value of candidate as text
set candidateActionNames to name of actions of candidate
if normalizeText(candidateValue) is normalizedMessageText then
if candidateActionNames contains "AXShowMenu" then
if matchingElements does not contain candidate then set end of matchingElements to candidate
end if
end if
end try
end repeat
end try
try
repeat with messageGroup in groups of messageListCandidate
try
repeat with candidate in static texts of messageGroup
try
set candidateValue to value of candidate as text
set candidateActionNames to name of actions of candidate
if normalizeText(candidateValue) is normalizedMessageText then
if candidateActionNames contains "AXShowMenu" then
if matchingElements does not contain candidate then set end of matchingElements to candidate
end if
end if
end try
end repeat
end try
end repeat
end try
end repeat
if (count of matchingElements) is 0 then error "Target message text was not visible as one exact targetable message bubble in the active chat."
if (count of matchingElements) is greater than 1 then error "Target message text matched multiple visible targetable message bubbles."
set targetElement to item 1 of matchingElements
perform action "AXShowMenu" of targetElement
delay 0.3
try
click menu item "삭제" of menu 1
on error
click menu item "Delete" of menu 1
end try
delay 0.5
set didChooseDeleteScope to false
repeat with labelText in deleteLabels
try
click button (labelText as text) of window 1
set didChooseDeleteScope to true
exit repeat
end try
try
click menu item (labelText as text) of menu 1
set didChooseDeleteScope to true
exit repeat
end try
end repeat
if didChooseDeleteScope is false then error "Could not choose the requested delete scope."
delay 0.3
set didConfirmDelete to false
try
click button "삭제" of window 1
set didConfirmDelete to true
end try
if didConfirmDelete is false then
try
click button "Delete" of window 1
set didConfirmDelete to true
end try
end if
if didConfirmDelete is false then error "Could not confirm the KakaoTalk delete dialog."
end tell
end tell
""".strip()
def _applescript_string(value: str) -> str:
return json.dumps(value, ensure_ascii=False)
def run_delete_automation(chat: str, target: DeleteTarget, *, everyone: bool) -> subprocess.CompletedProcess[str]:
script = build_delete_osascript(chat, target, everyone=everyone)
return run_command(["/usr/bin/osascript", "-e", script], check=True)
def handle_delete_command(args: argparse.Namespace) -> int:
delete_last = args.command == "delete-last"
message_id = None if delete_last else args.message_id
resolved = resolve_auth(
refresh=args.refresh_auth,
cache_path=Path(args.cache_path).expanduser(),
user_id_override=args.user_id,
uuid_override=args.uuid,
max_user_id=args.max_user_id,
workers=args.workers,
chunk_size=args.chunk_size,
)
messages = load_messages_for_delete(args.chat, resolved, limit=args.limit)
target = select_delete_target(messages, message_id=message_id, delete_last=delete_last, everyone=args.everyone)
if args.dry_run:
scope = "everyone" if args.everyone else "me"
print(
f"DRY RUN: Would delete message_id={target.message_id} "
f"from chat '{args.chat}' for {scope}: {target.text}"
)
return 0
run_delete_automation(args.chat, target, everyone=args.everyone)
print(f"Deleted message_id={target.message_id} from chat '{args.chat}'.")
return 0
def main(argv: Sequence[str] | None = None) -> int:
parser = build_parser()
@ -596,6 +916,11 @@ def main(argv: Sequence[str] | None = None) -> int:
print(render_auth(resolved, output_format=args.format, cache_path=cache_path))
return 0
if args.command in {"delete", "delete-last"}:
if forwarded_args:
raise AuthResolutionError(f"Unexpected delete arguments: {' '.join(forwarded_args)}")
return handle_delete_command(args)
resolved = resolve_auth(
refresh=args.refresh_auth,
cache_path=cache_path,
@ -628,6 +953,17 @@ def build_parser() -> argparse.ArgumentParser:
add_auth_options(passthrough)
passthrough.add_argument("--refresh-auth", action="store_true", help="Refresh cached auth before running.")
delete_parser = subparsers.add_parser("delete", help="Delete one KakaoTalk message by local message id via UI automation.")
add_auth_options(delete_parser)
add_delete_options(delete_parser)
delete_parser.add_argument("chat", help="Chat name to open (substring match).")
delete_parser.add_argument("message_id", type=positive_int, help="Local KakaoTalk message id from messages --json.")
delete_last_parser = subparsers.add_parser("delete-last", help="Delete the latest outbound message in a chat via UI automation.")
add_auth_options(delete_last_parser)
add_delete_options(delete_last_parser)
delete_last_parser.add_argument("chat", help="Chat name to open (substring match).")
return parser
@ -640,6 +976,13 @@ def add_auth_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--chunk-size", type=positive_int, default=DEFAULT_CHUNK_SIZE)
def add_delete_options(parser: argparse.ArgumentParser) -> None:
parser.add_argument("--everyone", action="store_true", help="Use KakaoTalk's delete-for-everyone UI option.")
parser.add_argument("--dry-run", action="store_true", help="Validate and print the deletion plan without touching the UI.")
parser.add_argument("--refresh-auth", action="store_true", help="Refresh cached auth before resolving message metadata.")
parser.add_argument("--limit", type=positive_int, default=200, help="Messages to inspect when resolving delete target metadata.")
def non_negative_int(value: str) -> int:
integer = int(value)
if integer < 0:

View file

@ -242,13 +242,213 @@ class KakaoTalkMacHelperTests(unittest.TestCase):
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
kakaotalk_mac.build_passthrough_command("query", auth, ["DELETE FROM chat_logs"])
def test_build_parser_only_exposes_read_only_commands(self) -> None:
def test_build_parser_exposes_safe_helper_commands_without_raw_query(self) -> None:
parser = kakaotalk_mac.build_parser()
subcommands = parser._subparsers._group_actions[0].choices
self.assertEqual(sorted(subcommands), ["auth", "chats", "messages", "schema", "search"])
self.assertEqual(sorted(subcommands), ["auth", "chats", "delete", "delete-last", "messages", "schema", "search"])
self.assertNotIn("query", subcommands)
def test_build_parser_exposes_delete_commands_with_safe_dry_run(self) -> None:
parser = kakaotalk_mac.build_parser()
subcommands = parser._subparsers._group_actions[0].choices
self.assertIn("delete", subcommands)
self.assertIn("delete-last", subcommands)
parsed = parser.parse_args(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
self.assertEqual(parsed.command, "delete")
self.assertEqual(parsed.chat, "팀 공지방")
self.assertEqual(parsed.message_id, 42)
self.assertTrue(parsed.everyone)
self.assertTrue(parsed.dry_run)
def test_select_delete_target_by_message_id_requires_matching_outbound_message(self) -> None:
messages = [
{"id": 41, "text": "older", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 42, "text": "sent follow-up", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertEqual(target.message_id, 42)
self.assertEqual(target.text, "sent follow-up")
self.assertTrue(target.is_from_me)
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
kakaotalk_mac.select_delete_target(messages, message_id=404, delete_last=False, everyone=False)
def test_select_delete_target_rejects_non_outbound_message_before_delete_for_me(self) -> None:
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("sent by this KakaoTalk account", str(context.exception))
def test_select_delete_last_uses_most_recent_message_from_me(self) -> None:
messages = [
{"id": 100, "text": "latest inbound", "is_from_me": False, "timestamp": "2026-05-14T00:02:00Z"},
{"id": 99, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
{"id": 98, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=True)
self.assertEqual(target.message_id, 99)
self.assertEqual(target.text, "latest outbound")
def test_select_delete_last_sorts_unordered_messages_by_timestamp_then_id(self) -> None:
messages = [
{"id": 40, "text": "older outbound", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 42, "text": "latest outbound", "is_from_me": True, "timestamp": "2026-05-14T00:02:00Z"},
{"id": 41, "text": "middle outbound", "is_from_me": True, "timestamp": "2026-05-14T00:01:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
self.assertEqual(target.message_id, 42)
self.assertEqual(target.text, "latest outbound")
def test_select_delete_last_uses_id_as_tiebreaker_for_equal_timestamps(self) -> None:
messages = [
{"id": 40, "text": "same time older id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
{"id": 43, "text": "same time newer id", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"},
]
target = kakaotalk_mac.select_delete_target(messages, message_id=None, delete_last=True, everyone=False)
self.assertEqual(target.message_id, 43)
self.assertEqual(target.text, "same time newer id")
def test_select_delete_target_rejects_everyone_for_non_outbound_message(self) -> None:
messages = [{"id": 42, "text": "inbound", "is_from_me": False}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
self.assertIn("--everyone", str(context.exception))
def test_build_delete_osascript_mentions_chat_text_and_delete_scope(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertIn("팀 공지방", script)
self.assertIn("테스트 메시지", script)
self.assertIn("모두에게서 삭제", script)
self.assertIn("Delete for Everyone", script)
self.assertIn("matchingElements", script)
self.assertIn("Could not choose the requested delete scope", script)
def test_build_delete_osascript_uses_fail_closed_exact_transcript_resolver(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertNotIn("entire contents of front window", script)
self.assertNotIn("contains messageText", script)
self.assertNotIn("contains chatName", script)
self.assertIn("set normalizedMessageText to normalizeText(messageText)", script)
self.assertIn("set normalizedChatName to normalizeText(chatName)", script)
self.assertIn("if normalizeText(candidateValue) is normalizedMessageText then", script)
self.assertIn("if normalizeText(chatCandidateValue) is normalizedChatName then", script)
self.assertIn("set messageListCandidates to", script)
self.assertIn("AXShowMenu", script)
self.assertIn("Target message text matched multiple visible targetable message bubbles", script)
self.assertIn("Could not verify the active KakaoTalk chat", script)
self.assertNotIn("set messageTimestamp to", script)
def test_run_delete_dry_run_validates_target_but_skips_ui_side_effect(self) -> None:
stdout = io.StringIO()
auth = make_resolved_auth()
messages = [{"id": 42, "text": "검증된 메시지", "is_from_me": True, "timestamp": "2026-05-14T00:00:00Z"}]
with (
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=auth) as resolve_auth,
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=messages) as load_messages,
mock.patch.object(kakaotalk_mac, "run_delete_automation") as run_delete,
mock.patch("sys.stdout", stdout),
):
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "42", "--everyone", "--dry-run"])
self.assertEqual(exit_code, 0)
resolve_auth.assert_called_once()
load_messages.assert_called_once_with("팀 공지방", auth, limit=200)
run_delete.assert_not_called()
self.assertIn("DRY RUN", stdout.getvalue())
self.assertIn("message_id=42", stdout.getvalue())
self.assertIn("검증된 메시지", stdout.getvalue())
def test_run_delete_dry_run_fails_when_message_id_is_missing(self) -> None:
stderr = io.StringIO()
with (
mock.patch.object(kakaotalk_mac, "resolve_auth", return_value=make_resolved_auth()),
mock.patch.object(kakaotalk_mac, "load_messages_for_delete", return_value=[]),
mock.patch("sys.stderr", stderr),
):
exit_code = kakaotalk_mac.main(["delete", "팀 공지방", "404", "--dry-run"])
self.assertEqual(exit_code, 1)
self.assertIn("Message id 404", stderr.getvalue())
def test_select_delete_target_rejects_duplicate_visible_text(self) -> None:
messages = [
{"id": 42, "text": "same", "is_from_me": True},
{"id": 41, "text": "same", "is_from_me": True},
]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=True)
self.assertIn("same normalized visible text", str(context.exception))
def test_select_delete_target_rejects_duplicate_normalized_visible_text(self) -> None:
messages = [
{"id": 42, "text": "same visible text", "is_from_me": True},
{"id": 41, "text": "same visible text", "is_from_me": True},
]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("same normalized visible text", str(context.exception))
def test_select_delete_target_rejects_empty_or_non_text_delete_target(self) -> None:
messages = [{"id": 42, "text": " ", "type": "photo", "is_from_me": True}]
with self.assertRaises(kakaotalk_mac.AuthResolutionError) as context:
kakaotalk_mac.select_delete_target(messages, message_id=42, delete_last=False, everyone=False)
self.assertIn("non-empty text", str(context.exception))
def test_build_delete_osascript_fails_when_final_confirmation_is_missing(self) -> None:
target = kakaotalk_mac.DeleteTarget(
message_id=42,
text="테스트 메시지",
timestamp="2026-05-14T00:00:00Z",
is_from_me=True,
)
script = kakaotalk_mac.build_delete_osascript("팀 공지방", target, everyone=True)
self.assertIn("set didConfirmDelete to false", script)
self.assertIn("set didConfirmDelete to true", script)
self.assertIn("if didConfirmDelete is false then error", script)
self.assertIn("Could not confirm the KakaoTalk delete dialog", script)
def test_build_parser_rejects_negative_max_user_id(self) -> None:
parser = kakaotalk_mac.build_parser()
stderr = io.StringIO()