mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
parent
47ed22b076
commit
5a6dcedb99
5 changed files with 595 additions and 9 deletions
|
|
@ -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) |
|
||||
|
|
|
|||
|
|
@ -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` 을 사용한다.
|
||||
|
|
|
|||
|
|
@ -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 를 포함하므로 본인 계정에서만 유지하고 공유하지 않는다.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue