mirror of
https://github.com/NomaDamas/k-skill.git
synced 2026-06-24 02:04:11 +00:00
commit
e735abe8a4
10 changed files with 225 additions and 1711 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 아카이브 검색 | `kakaotalk-mac` | `katok`으로 macOS 카카오톡 로컬 아카이브를 동기화하고 keyword/BM25/semantic 검색 | 불필요(로컬 앱/권한 필요) | [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md) |
|
||||
| 서울 지하철 도착정보 조회 | `seoul-subway-arrival` | 서울 지하철 역 기준 실시간 도착 예정 열차 확인 | 불필요 | [서울 지하철 도착정보 가이드](docs/features/seoul-subway-arrival.md) |
|
||||
| 서울 실시간 혼잡도 조회 | `seoul-density` | 서울 주요 121개 핫스팟의 실시간 혼잡도 단계와 추정 인구 조회 | 불필요 | [서울 실시간 혼잡도 가이드](docs/features/seoul-density.md) |
|
||||
| 서울 따릉이 실시간 대여소 조회 | `seoul-bike` | 현재 좌표 주변 또는 대여소 이름 기준 따릉이 대여 가능 자전거와 빈 거치대 조회 | 불필요 | [서울 따릉이 실시간 대여소 가이드](docs/features/seoul-bike.md) |
|
||||
|
|
@ -157,7 +157,7 @@ Claude Code, Codex, OpenCode, OpenClaw/ClawHub 등 각종 코딩 에이전트
|
|||
- [고속버스 예매](docs/features/express-bus-booking.md)
|
||||
- [시외버스 예매](docs/features/intercity-bus-booking.md)
|
||||
- [자연휴양림 빈 객실 조회](docs/features/foresttrip-vacancy.md)
|
||||
- [카카오톡 Mac CLI](docs/features/kakaotalk-mac.md)
|
||||
- [카카오톡 Mac 아카이브 검색](docs/features/kakaotalk-mac.md)
|
||||
- [서울 지하철 도착정보 조회](docs/features/seoul-subway-arrival.md)
|
||||
- [서울 실시간 혼잡도 조회](docs/features/seoul-density.md)
|
||||
- [한국 대중교통 길찾기 가이드](docs/features/korean-transit-route.md)
|
||||
|
|
|
|||
|
|
@ -1,106 +1,113 @@
|
|||
# 카카오톡 Mac CLI 가이드
|
||||
# 카카오톡 Mac 아카이브 검색 가이드
|
||||
|
||||
## 이 기능으로 할 수 있는 일
|
||||
|
||||
- macOS에서 카카오톡 최근 대화 목록 확인
|
||||
- 특정 채팅방 최근 메시지 읽기
|
||||
- 키워드로 전체 대화 검색
|
||||
- 나와의 채팅으로 안전하게 테스트 전송
|
||||
- 사용자 확인 후 특정 채팅방으로 메시지 전송
|
||||
- 로컬 메시지 ID로 후보를 고른 뒤 현재 보이는 정확히 하나의 UI bubble 삭제 자동화
|
||||
- Apple Silicon macOS에서 `katok`으로 카카오톡 로컬 대화 아카이브 생성
|
||||
- keyword, BM25, semantic 검색
|
||||
- 검색 결과의 chunk id로 원문, 주변 맥락, parent window 조회
|
||||
- 검색 전 freshness 확인과 sync/index 필요 여부 판단
|
||||
|
||||
이 가이드는 기존 `kakaotalk-mac` 스킬 경로를 유지하지만 실행 표면은 `katok` CLI다. 메시지 전송, 삭제, UI 자동화, 직접 DB 읽기, 인증 캐시 처리, 복호화 material 처리는 포함하지 않는다.
|
||||
|
||||
## 먼저 필요한 것
|
||||
|
||||
- macOS
|
||||
- Apple Silicon macOS
|
||||
- KakaoTalk for Mac 설치
|
||||
- Homebrew
|
||||
- `brew install silver-flight-group/tap/kakaocli`
|
||||
- `python3` 3.10+
|
||||
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
|
||||
- 터미널 앱에 **Full Disk Access** 와 **Accessibility** 권한 부여
|
||||
- Homebrew 또는 Cargo
|
||||
- `katok` CLI
|
||||
- 현재 터미널 앱의 Full Disk Access 권한
|
||||
|
||||
카카오톡 앱이 없으면 `mas` 로 먼저 설치할 수 있다.
|
||||
## 설치
|
||||
|
||||
Homebrew:
|
||||
|
||||
```bash
|
||||
brew install mas
|
||||
mas account
|
||||
mas install 869223134
|
||||
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
|
||||
brew install katok
|
||||
```
|
||||
|
||||
## 입력값
|
||||
Cargo:
|
||||
|
||||
- 채팅방 이름
|
||||
- 검색 키워드
|
||||
- 최근 범위(`--since 1h`, `--since 7d` 등)
|
||||
- 전송 메시지 본문
|
||||
- 삭제할 메시지 ID 또는 최근 보낸 메시지 삭제 여부
|
||||
- 테스트 여부(`--me`, `--dry-run`)
|
||||
```bash
|
||||
cargo install katok
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
```
|
||||
|
||||
Cargo 설치 후 `katok`이 보이지 않으면 `$HOME/.cargo/bin`을 shell PATH에 추가한다.
|
||||
|
||||
## 개인 정보와 안전 규칙
|
||||
|
||||
- Do not inspect local database internals from this skill.
|
||||
- Do not directly read KakaoTalk DB files.
|
||||
- Do not handle auth caches or decryption material.
|
||||
- live macOS 카카오톡 ingestion은 `katok sync --source macos --json`으로만 수행한다.
|
||||
- 검색 결과는 snippet과 chunk id 중심으로 먼저 다룬다.
|
||||
- 사용자가 특정 결과를 열어 달라고 하거나 chunk id를 제공했을 때만 chunk 원문을 조회한다.
|
||||
|
||||
## 기본 흐름
|
||||
|
||||
1. KakaoTalk for Mac 과 `kakaocli` 가 설치되어 있는지 확인한다.
|
||||
2. `kakaocli status`, `kakaocli auth` 로 권한과 DB 접근이 되는지 먼저 확인한다.
|
||||
3. `user_id` 자동 감지가 실패하면 helper `python3 scripts/kakaotalk_mac.py auth --refresh` 로 복구한다.
|
||||
4. 읽기/검색은 JSON 모드로 실행한 뒤 사람이 읽기 쉽게 요약한다.
|
||||
5. 전송은 먼저 `--me` 또는 `--dry-run` 으로 테스트한다.
|
||||
6. 삭제는 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 을 먼저 실행한다.
|
||||
7. 다른 사람에게 보내는 메시지는 항상 최종 확인 후에만 전송한다.
|
||||
1. `katok doctor --json`으로 freshness와 준비 상태를 확인한다.
|
||||
2. Full Disk Access 설정이 필요하면 `katok permissions macos`로 시스템 설정 화면을 연다.
|
||||
3. 앱 설치, container, DB 파일 접근 진단이 필요할 때만 `katok doctor --macos-probe --json`을 실행한다.
|
||||
4. 최신성이 중요하거나 sync 권장이 있으면 `katok sync --source macos --json`을 실행한다.
|
||||
5. semantic search 전에 index 권장이 있으면 `katok index --json`을 실행한다.
|
||||
6. 질의 성격에 따라 `katok search keyword`, `katok search bm25`, `katok search semantic`을 선택한다.
|
||||
7. 사용자가 지정한 결과만 `katok chunk get`, `katok chunk context`, `katok chunk parent`로 연다.
|
||||
|
||||
## 예시
|
||||
|
||||
```bash
|
||||
kakaocli status
|
||||
kakaocli auth
|
||||
python3 scripts/kakaotalk_mac.py auth --refresh
|
||||
python3 scripts/kakaotalk_mac.py chats --limit 10 --json
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1d --json
|
||||
python3 scripts/kakaotalk_mac.py search "회의" --json
|
||||
kakaocli chats --limit 10 --json
|
||||
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
|
||||
katok doctor --json
|
||||
katok permissions macos
|
||||
katok doctor --macos-probe --json
|
||||
katok sync --source macos --json
|
||||
katok index --json
|
||||
katok search keyword "계약서" --json
|
||||
katok search bm25 "지난주 미팅 자료" --json
|
||||
katok search semantic "최근에 논의한 세금 신고 일정" --json
|
||||
katok chunk get <chunk-id> --json
|
||||
katok chunk context <chunk-id> --json
|
||||
katok chunk parent <chunk-id> --json
|
||||
```
|
||||
|
||||
## helper 가 해결하는 문제
|
||||
## 검색 방식 선택
|
||||
|
||||
`kakaocli auth` 실패가 항상 “DB 파일이 없음”을 의미하지는 않는다. 실제 Mac 환경에서는:
|
||||
`katok search keyword`는 정확한 문자열, 이름, 계좌번호, 고유명사처럼 그대로 기억나는 값을 찾을 때 쓴다.
|
||||
|
||||
- container 안에 `KakaoTalk.db` 라는 이름 대신 **78자 hex 파일**이 DB 로 존재할 수 있다.
|
||||
- `kakaocli status` 는 정상이어도 `auth` 는 `user_id 자동 감지 실패` 로 끝날 수 있다.
|
||||
- 이 경우 plist 의 `AlertKakaoIDsList` 후보만으로는 부족하고, `DESIGNATEDFRIENDSREVISION:<SHA-512(user_id)>` 에서 실제 `user_id` 를 더 오래 찾아야 할 수 있다.
|
||||
`katok search bm25`는 여러 단어가 섞인 일반 질의에 쓴다.
|
||||
|
||||
helper `scripts/kakaotalk_mac.py` 는 그 얇은 read-only 어댑터 역할을 한다.
|
||||
`katok search semantic`은 표현이 정확히 기억나지 않지만 의미가 비슷한 대화를 찾을 때 쓴다. `katok doctor --json`에서 semantic index 갱신이 필요하다고 나오면 먼저 `katok index --json`을 실행한다.
|
||||
|
||||
- plist 에서 후보 `user_id` 와 active hash 를 읽는다.
|
||||
- hash recovery 가 필요하면 더 긴 검색으로 실제 `user_id` 를 찾는다.
|
||||
- 검증된 DB 경로와 SQLCipher key 를 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
|
||||
- 이후 read-only helper 명령 `chats`, `messages`, `search`, `schema` 를 cached `--db` / `--key` 와 함께 다시 실행한다.
|
||||
## chunk 조회
|
||||
|
||||
## 메시지 삭제
|
||||
|
||||
`delete` / `delete-last` 는 카카오톡 Mac UI의 삭제 메뉴를 Accessibility 로 자동화한다. 메시지 조회와 outbound 여부 검증은 로컬 DB에서 하고, 실제 삭제는 현재 보이는 UI bubble 에서 수행한다.
|
||||
검색 결과에서 더 넓은 맥락이 필요할 때만 chunk 명령을 사용한다.
|
||||
|
||||
```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
|
||||
katok chunk get <chunk-id> --json
|
||||
katok chunk context <chunk-id> --json
|
||||
katok chunk parent <chunk-id> --json
|
||||
```
|
||||
|
||||
- `--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 이다.
|
||||
- `chunk get`: 해당 chunk 원문 조회
|
||||
- `chunk context`: 같은 채팅방의 바로 앞뒤 micro chunk 조회
|
||||
- `chunk parent`: semantic search가 사용한 더 큰 parent window 조회
|
||||
|
||||
## Synthetic QA
|
||||
|
||||
실제 카카오톡 설치 없이 upstream fixture로 테스트할 때만 아래 경로를 쓴다.
|
||||
|
||||
```bash
|
||||
katok sync --source fixture tests/fixtures/kakao/replies.jsonl --json
|
||||
KATOK_EMBEDDER=local-test katok index --json
|
||||
KATOK_EMBEDDER=mock katok index --json
|
||||
```
|
||||
|
||||
실사용 경로에서는 fixture, mock embedder, 원격 embedding endpoint를 사용하지 않는다.
|
||||
|
||||
## 주의할 점
|
||||
|
||||
- **Full Disk Access** 가 없으면 읽기 명령도 실패할 수 있다.
|
||||
- **Accessibility** 가 없으면 전송, 삭제, harvest 계열 자동화가 실패한다.
|
||||
- macOS 전용이므로 Windows/Linux 대체 구현으로 넘어가지 않는다.
|
||||
- 다른 사람에게 보내는 메시지는 자동 전송하지 말고 확인을 먼저 받는다.
|
||||
- 삭제 자동화는 되돌리기 어렵기 때문에 `--dry-run` 으로 대상과 범위를 확인한다.
|
||||
- helper cache 는 로컬 auth material 을 담으므로 본인 장비에서만 보관한다.
|
||||
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
|
||||
- Apple Silicon macOS 전용이다.
|
||||
- Intel macOS는 packaged local EmbeddingGemma 경로의 지원 대상이 아니다.
|
||||
- Full Disk Access는 사용자가 System Settings에서 직접 허용해야 한다.
|
||||
- `katok doctor --macos-probe --json`은 macOS app-data 접근 prompt를 띄울 수 있으므로 setup 진단이 필요할 때만 실행한다.
|
||||
- 이 스킬은 read/search/retrieve 전용이며 메시지 전송과 삭제를 지원하지 않는다.
|
||||
|
|
|
|||
|
|
@ -319,14 +319,22 @@ HWP Node API 예시는 전역 `NODE_PATH` 대신 로컬 프로젝트에 `npm ins
|
|||
|
||||
### macOS 바이너리
|
||||
|
||||
카카오톡 Mac CLI는 npm 패키지가 아니라 Homebrew tap 설치를 사용한다.
|
||||
카카오톡 Mac 아카이브 검색은 npm 패키지가 아니라 `katok` CLI 설치를 사용한다.
|
||||
|
||||
```bash
|
||||
brew install silver-flight-group/tap/kakaocli
|
||||
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
|
||||
brew install katok
|
||||
brew tap JungHoonGhae/tossinvest-cli
|
||||
brew install tossctl
|
||||
```
|
||||
|
||||
Cargo로 설치할 수도 있다.
|
||||
|
||||
```bash
|
||||
cargo install katok
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
```
|
||||
|
||||
`toss-securities` 스킬은 공식 토스증권 Open API를 우선 사용한다. 공식 경로를 쓰려면 발급받은 자격증명을 사용자 환경변수로 둔다(공유 프록시로 보내지 않고 토스 서버로 직접 호출한다). `tossctl` 설치는 공식 credentials가 없을 때의 fallback 경로용이다.
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -94,8 +94,8 @@
|
|||
- LH 임대공고문 상세(공급정보) endpoint: http://apis.data.go.kr/B552555/lhLeaseNoticeDtlInfo1/getLeaseNoticeDtlInfo1
|
||||
- LH 청약 샘플 reference 구현(heereal/Bunyang_MoeumZip): https://github.com/heereal/Bunyang_MoeumZip
|
||||
- 법제처 국가법령정보 공동활용 Open API (k-skill-proxy `/v1/korean-law/*` upstream): https://open.law.go.kr — DRF `lawSearch.do` (검색) / `lawService.do` (본문)
|
||||
- `silver-flight-group/kakaocli`: https://github.com/silver-flight-group/kakaocli
|
||||
- KakaoTalk Mac 설치 참고(`mas`): https://velog.io/@bonjugi/%EB%A7%A5%EB%B6%81-M1%EC%97%90-homebrew%EB%A1%9C-node-vscode-%EC%B9%B4%EC%B9%B4%EC%98%A4%ED%86%A1-%EC%84%A4%EC%B9%98%ED%95%98%EA%B8%B0
|
||||
- `NomaDamas/katok`: https://github.com/NomaDamas/katok
|
||||
- `katok` macOS first-run docs: https://github.com/NomaDamas/katok/blob/main/docs/macos-first-run.md
|
||||
- 동행복권 로또 결과 페이지: https://www.dhlottery.co.kr/lt645/result
|
||||
- 동행복권 지난 회차 JSON 표면: https://www.dhlottery.co.kr/lt645/selectPstLt645InfoNew.do
|
||||
- 바른한글 메인: https://nara-speller.co.kr/speller/
|
||||
|
|
|
|||
|
|
@ -1,223 +1,199 @@
|
|||
---
|
||||
name: kakaotalk-mac
|
||||
description: Use kakaocli on macOS to read KakaoTalk chats, search messages, send replies after explicit confirmation, and delete sent messages with explicit operator intent.
|
||||
description: Search local KakaoTalk archives on Apple Silicon macOS through the katok CLI.
|
||||
license: MIT
|
||||
metadata:
|
||||
category: messaging
|
||||
locale: ko-KR
|
||||
phase: v1.5
|
||||
phase: v2
|
||||
---
|
||||
|
||||
# KakaoTalk Mac CLI
|
||||
# KakaoTalk katok Search
|
||||
|
||||
## What this skill does
|
||||
|
||||
`kakaocli` 와 이 저장소 helper를 사용해 macOS에서 카카오톡 대화 목록을 확인하고, 메시지를 검색하고, 필요할 때 답장하거나 보낸 메시지를 삭제한다.
|
||||
`katok` CLI를 유일한 실행 표면으로 사용해 macOS 카카오톡 대화를 로컬 아카이브와 검색 인덱스로 동기화하고, keyword/BM25/semantic 검색과 chunk 조회를 수행한다.
|
||||
|
||||
이 스킬은 **macOS + 카카오톡 Mac 앱 설치**를 전제로 한다. 공식 Kakao API를 쓰는 것이 아니라 로컬 데이터베이스 읽기와 macOS 접근성 자동화 위에서 동작하므로, 권한과 안전 규칙을 먼저 확인해야 한다.
|
||||
이 스킬은 기존 `kakaotalk-mac` 설치 경로를 유지하지만 내부 동작은 `katok` 기반이다. 메시지 전송, 삭제, UI 자동화, 직접 DB 읽기, 인증 캐시 처리, 복호화 material 처리는 이 스킬의 범위가 아니다.
|
||||
|
||||
## Privacy Rules
|
||||
|
||||
- Do not inspect local database internals from this skill.
|
||||
- Do not directly read KakaoTalk DB files.
|
||||
- Do not handle auth caches or decryption material.
|
||||
- Use `katok sync --source macos --json` for live macOS KakaoTalk ingestion.
|
||||
- Search commands should return snippets and chunk ids first.
|
||||
- Retrieve full chunk content only when the user explicitly asks to open a result or provides a chunk id.
|
||||
|
||||
## When to use
|
||||
|
||||
- "카카오톡 최근 대화 목록 보여줘"
|
||||
- "특정 채팅방 최근 메시지 찾아줘"
|
||||
- "카카오톡 메시지 검색해줘"
|
||||
- "내 카톡으로 테스트 메시지 보내줘"
|
||||
- "답장 초안은 만들되 실제 전송 전에는 꼭 확인받아"
|
||||
- "카카오톡에서 특정 키워드 검색해줘"
|
||||
- "카톡에서 지난 회의/계약/약속 이야기 찾아줘"
|
||||
- "이 검색 결과 chunk를 열어줘"
|
||||
- "최근 대화가 반영됐는지 확인하고 검색해줘"
|
||||
|
||||
## When not to use
|
||||
|
||||
- macOS가 아닌 환경
|
||||
- 카카오톡 Mac 앱이 설치되어 있지 않은 환경
|
||||
- 사용자 확인 없이 다른 사람에게 메시지를 바로 보내야 하는 작업
|
||||
- 카카오 공식 API 범위 안에서 해결 가능한 서버-투-서버 연동 작업
|
||||
- Intel Mac에서 로컬 EmbeddingGemma semantic index가 필요한 경우
|
||||
- 카카오톡 메시지를 보내거나 삭제해야 하는 경우
|
||||
- 카카오톡 DB 파일, 인증 캐시, 복호화 material을 직접 다루라는 요청
|
||||
- 서버-투-서버 공식 Kakao API 연동 요청
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- macOS
|
||||
- Apple Silicon macOS
|
||||
- KakaoTalk for Mac 설치
|
||||
- Homebrew
|
||||
- Mac App Store 로그인(`mas` 사용 시)
|
||||
- `kakaocli` 설치
|
||||
- `python3` 3.10+
|
||||
- 이 저장소의 helper `scripts/kakaotalk_mac.py`
|
||||
- 터미널 앱에 **Full Disk Access** 와 **Accessibility** 권한 부여
|
||||
- Homebrew 또는 Cargo
|
||||
- `katok` CLI 설치
|
||||
- 현재 터미널 앱에 Full Disk Access 권한
|
||||
|
||||
## Inputs
|
||||
## Install katok
|
||||
|
||||
- 채팅방 이름 또는 검색 키워드
|
||||
- 읽기 범위: 최근 N개, `--since 1h`, `--since 7d` 등
|
||||
- 전송할 메시지 본문
|
||||
- 삭제할 로컬 메시지 ID 또는 최근 보낸 메시지 삭제 여부
|
||||
- 테스트 여부 (`--me`, `--dry-run`)
|
||||
Homebrew:
|
||||
|
||||
```bash
|
||||
brew tap NomaDamas/katok https://github.com/NomaDamas/katok.git
|
||||
brew install katok
|
||||
```
|
||||
|
||||
Cargo:
|
||||
|
||||
```bash
|
||||
cargo install katok
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
```
|
||||
|
||||
설치 후 CLI가 보이는지 확인한다.
|
||||
|
||||
```bash
|
||||
katok --help
|
||||
katok doctor --json
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 0. Install KakaoTalk for Mac first when missing
|
||||
|
||||
카카오톡 Mac 앱이 없으면 먼저 설치한다. `mas` 를 쓰려면 App Store 로그인 상태여야 한다.
|
||||
### 1. Check readiness without prompting for app data
|
||||
|
||||
```bash
|
||||
brew install mas
|
||||
mas account
|
||||
mas install 869223134
|
||||
katok doctor --json
|
||||
```
|
||||
|
||||
`mas install` 이 막히면 App Store 앱에서 먼저 로그인한 뒤 다시 시도한다.
|
||||
`doctor --json`의 `freshness` 섹션에서 마지막 sync/index 상태를 확인한다. 이 기본 doctor는 macOS app-data probe를 실행하지 않으므로 권한 prompt를 띄우지 않는 준비 상태 점검에 적합하다.
|
||||
|
||||
### 1. Install `kakaocli`
|
||||
### 2. Open macOS permission settings when needed
|
||||
|
||||
공식 저장소 기준 권장 설치는 Homebrew tap 이다.
|
||||
Full Disk Access 설정이 필요하면 사용자가 직접 허용할 수 있도록 설정 화면을 연다.
|
||||
|
||||
```bash
|
||||
brew install silver-flight-group/tap/kakaocli
|
||||
katok permissions macos
|
||||
```
|
||||
|
||||
설치 후 바로 상태를 확인한다.
|
||||
KakaoTalk UI 자동화는 이 스킬 범위가 아니지만, upstream 진단을 위해 Accessibility 설정 화면까지 열어야 하는 경우에만 다음 명령을 쓴다.
|
||||
|
||||
```bash
|
||||
kakaocli status
|
||||
katok permissions macos --accessibility
|
||||
```
|
||||
|
||||
### 2. Grant the required macOS permissions
|
||||
### 3. Run explicit macOS setup diagnostics only when needed
|
||||
|
||||
**System Settings > Privacy & Security** 에서 현재 사용하는 터미널 앱(iTerm, Terminal, Warp 등)에 아래 권한을 준다.
|
||||
|
||||
- **Full Disk Access**: 카카오톡 로컬 데이터베이스 읽기용
|
||||
- **Accessibility**: 메시지 전송, harvest, inspect 같은 UI 자동화용
|
||||
|
||||
기본 규칙:
|
||||
|
||||
- `status` / `auth` / `chats` 같은 읽기 명령도 Full Disk Access 가 필요하다.
|
||||
- `send`, `delete`, `delete-last`, `harvest`, `inspect` 류 작업은 Accessibility 권한까지 필요하다.
|
||||
|
||||
### 3. Verify read access before attempting side effects
|
||||
|
||||
먼저 읽기 경로가 되는지 확인한다.
|
||||
카카오톡 앱 설치, container, DB 파일 접근 같은 macOS source adapter 상태를 확인해야 할 때만 probe를 실행한다. 이 명령은 macOS가 app-data 접근 prompt를 띄울 수 있다.
|
||||
|
||||
```bash
|
||||
kakaocli status
|
||||
kakaocli auth
|
||||
kakaocli chats --limit 10 --json
|
||||
katok doctor --macos-probe --json
|
||||
```
|
||||
|
||||
`auth` 가 성공하면 읽기 경로는 준비된 것이다.
|
||||
### 4. Sync local KakaoTalk archives
|
||||
|
||||
### 3.5. Use the helper when `kakaocli auth` fails on `user_id` auto-detection
|
||||
|
||||
실제 macOS 환경에서는 `KakaoTalk.db` 라는 literal 파일이 없어도, container 안의 **78자 hex 파일**이 실제 SQLCipher DB 인 경우가 있다. 이때 `kakaocli status` 는 정상인데 `kakaocli auth` 만 실패하는 대표 원인은:
|
||||
|
||||
- `AlertKakaoIDsList` 후보로는 복호화가 안 됨
|
||||
- plist 의 `DESIGNATEDFRIENDSREVISION:<sha512(user_id)>` 만 남아 있음
|
||||
- upstream `kakaocli` 의 기본 `user_id` brute-force 시간이 짧아서 auto-detection 이 실패함
|
||||
|
||||
이 저장소는 그 구간만 보완하는 **read-only helper** 를 함께 제공한다.
|
||||
최신 대화가 중요하거나 `freshness.recommendation.sync_before_search`가 true이면 검색 전에 sync한다.
|
||||
|
||||
```bash
|
||||
python3 scripts/kakaotalk_mac.py auth --refresh
|
||||
python3 scripts/kakaotalk_mac.py chats --limit 10 --json
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1d --json
|
||||
python3 scripts/kakaotalk_mac.py search "회의" --json
|
||||
katok sync --source macos --json
|
||||
```
|
||||
|
||||
- helper 는 plist 의 `AlertKakaoIDsList` 와 `DESIGNATEDFRIENDSREVISION` hash 를 읽는다.
|
||||
- 후보 `user_id` 가 모두 실패하면 SHA-512 preimage search 로 실제 `user_id` 를 더 오래 찾는다.
|
||||
- 성공한 `user_id`, DB 경로, derived key 는 `~/.cache/k-skill/kakaotalk-mac-auth.json` 에 캐시한다.
|
||||
- 이후 read-only helper 명령(`chats`, `messages`, `search`, `schema`)은 cached `--db` / `--key` 를 붙여 `kakaocli` 를 다시 호출한다.
|
||||
|
||||
### 4. Read or search messages
|
||||
설정 파일의 기본 source adapter를 쓰는 경우:
|
||||
|
||||
```bash
|
||||
kakaocli messages --chat "지수" --since 1h --json
|
||||
kakaocli search "점심" --json
|
||||
katok sync --json
|
||||
```
|
||||
|
||||
helper 경유 예시:
|
||||
### 5. Build or refresh the semantic index
|
||||
|
||||
semantic search 전 `freshness.recommendation.index_before_semantic_search`가 true이거나 방금 sync한 내용을 semantic 검색에 반영해야 하면 index를 만든다.
|
||||
|
||||
```bash
|
||||
python3 scripts/kakaotalk_mac.py messages --chat "지수" --since 1h --json
|
||||
python3 scripts/kakaotalk_mac.py search "점심" --json
|
||||
katok index --json
|
||||
```
|
||||
|
||||
응답은 가능하면 JSON 모드로 받고, 사람이 읽기 쉽게 다시 요약한다.
|
||||
`katok index`는 기본적으로 로컬 `embeddinggemma-300m-q4` embedder를 사용한다. Python, Jina, TEI, 별도 HTTP embedding server를 요구하지 않는다.
|
||||
|
||||
### 5. Use safe testing before real sends
|
||||
### 6. Search with the narrowest useful mode
|
||||
|
||||
실제 전송 전에 먼저 자기 자신에게 테스트하거나 dry-run 으로 확인한다.
|
||||
정확한 문자열, 이름, 계좌번호, 고유명사는 keyword search를 먼저 쓴다.
|
||||
|
||||
```bash
|
||||
kakaocli send --me _ "테스트 메시지"
|
||||
kakaocli send --dry-run "채팅방 이름" "보낼 문장"
|
||||
katok search keyword "검색어" --json
|
||||
```
|
||||
|
||||
`--me` 는 나와의 채팅으로 보내므로 가장 안전한 테스트 경로다.
|
||||
|
||||
### 6. Confirm before sending to other people
|
||||
|
||||
다른 사람이나 단체방으로 보내기 전에는 반드시 사용자의 최종 확인을 받는다.
|
||||
|
||||
확인 전에는 아래만 준비한다.
|
||||
|
||||
- 대상 채팅방 이름
|
||||
- 전송할 문장
|
||||
- 왜 이 문장을 보내는지 한 줄 설명
|
||||
|
||||
확인을 받았을 때만 전송한다.
|
||||
여러 단어가 섞인 일반 질의는 BM25를 쓴다.
|
||||
|
||||
```bash
|
||||
kakaocli send "채팅방 이름" "보낼 문장"
|
||||
katok search bm25 "지난주 미팅 자료" --json
|
||||
```
|
||||
|
||||
### 7. Delete a sent message only with explicit operator intent
|
||||
|
||||
삭제는 카카오톡 Mac UI(우클릭 → 삭제)를 접근성으로 자동화한다. 먼저 `messages --json` 으로 로컬 메시지 ID와 보낸 메시지 여부를 확인하고, 항상 `--dry-run` 으로 대상 채팅방/메시지를 확인한 뒤 실행한다. `--everyone` 은 내가 보낸 메시지에만 허용된다.
|
||||
표현이 정확히 기억나지 않는 의미 기반 질의는 semantic search를 쓴다.
|
||||
|
||||
```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
|
||||
katok search semantic "최근에 논의한 세금 신고 일정" --json
|
||||
```
|
||||
|
||||
주의:
|
||||
### 7. Retrieve explicit chunks only when needed
|
||||
|
||||
- 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
|
||||
|
||||
자동 로그인 편의를 원할 때만 자격증명을 저장한다.
|
||||
검색 결과는 먼저 snippet과 chunk id 중심으로 요약한다. 사용자가 특정 결과를 열어 달라고 하거나 chunk id를 제공했을 때만 원문 chunk를 조회한다.
|
||||
|
||||
```bash
|
||||
kakaocli login
|
||||
kakaocli login --status
|
||||
katok chunk get <chunk-id> --json
|
||||
katok chunk context <chunk-id> --json
|
||||
katok chunk parent <chunk-id> --json
|
||||
```
|
||||
|
||||
비밀번호를 채팅창에 보내라고 요구하지 않는다. 사용자가 직접 로컬 터미널에서 입력하게 한다.
|
||||
- `katok chunk get <chunk-id> --json`: 해당 chunk 원문 조회
|
||||
- `katok chunk context <chunk-id> --json`: 같은 채팅방의 직전/직후 micro chunk 조회
|
||||
- `katok chunk parent <chunk-id> --json`: semantic search parent window 조회
|
||||
|
||||
## Synthetic QA only
|
||||
|
||||
실제 카카오톡 설치 없이 upstream fixture로 테스트할 때만 fixture source와 deterministic embedder를 사용한다.
|
||||
|
||||
```bash
|
||||
katok sync --source fixture tests/fixtures/kakao/replies.jsonl --json
|
||||
KATOK_EMBEDDER=local-test katok index --json
|
||||
KATOK_EMBEDDER=mock katok index --json
|
||||
```
|
||||
|
||||
실사용 경로에서는 fixture, mock embedder, 원격 embedding endpoint를 사용하지 않는다.
|
||||
|
||||
## Done when
|
||||
|
||||
- 읽기 요청이면 상태 확인 + 대화/메시지 조회 결과가 정리되어 있다
|
||||
- 검색 요청이면 키워드 기준 결과가 정리되어 있다
|
||||
- 전송 요청이면 테스트(`--me` 또는 `--dry-run`)와 사용자 확인이 끝난 뒤 실제 전송 여부가 명확하다
|
||||
- 삭제 요청이면 `messages --json` 으로 대상 ID를 확인하고 `delete` / `delete-last --dry-run` 검증 뒤 실행 결과가 명확하다
|
||||
- readiness 요청이면 `katok doctor --json` 결과와 freshness 권장사항을 요약했다.
|
||||
- 최신 검색 요청이면 필요한 경우 `katok sync --source macos --json`과 `katok index --json` 실행 여부를 명확히 했다.
|
||||
- 검색 요청이면 keyword/BM25/semantic 중 선택한 이유와 JSON 검색 결과 요약을 제공했다.
|
||||
- chunk 조회 요청이면 사용자가 지정한 chunk id에 대해서만 `katok chunk get/context/parent` 결과를 요약했다.
|
||||
|
||||
## Failure modes
|
||||
|
||||
- `katok` 미설치 또는 Cargo binary PATH 누락
|
||||
- Apple Silicon macOS가 아님
|
||||
- KakaoTalk for Mac 미설치
|
||||
- App Store 로그인 누락으로 `mas install` 실패
|
||||
- Full Disk Access 미부여
|
||||
- Accessibility 미부여
|
||||
- `status` 는 정상인데 `auth` 만 실패하는 `user_id` auto-detection / key mismatch 케이스
|
||||
- 채팅방 이름 substring 이 애매해서 잘못된 후보가 여러 개 잡힘
|
||||
- `katok doctor --macos-probe --json`에서 container 또는 DB 파일 접근 실패
|
||||
- sync 전이라 local archive가 비어 있음
|
||||
- semantic index가 오래되었거나 아직 생성되지 않음
|
||||
- 검색 결과가 snippet/chunk id만으로 충분하지 않아 명시적 chunk 조회가 필요함
|
||||
|
||||
## Notes
|
||||
|
||||
- 이 스킬은 macOS 전용이다.
|
||||
- 다른 사람에게 보내는 메시지는 항상 confirm before sending 원칙을 지킨다.
|
||||
- 삭제는 되돌리기 어렵고 UI 상태에 민감하므로 먼저 `--dry-run` 으로 대상과 범위를 확인한다.
|
||||
- 첫 검증은 `kakaocli status` 와 `kakaocli auth` 부터 시작하는 편이 안전하다.
|
||||
- `kakaocli auth` 가 `User ID: auto-detection failed` 로 멈추면 helper 경로를 우선 사용한다.
|
||||
- helper cache 는 로컬 SQLCipher key 를 포함하므로 본인 계정에서만 유지하고 공유하지 않는다.
|
||||
- 기본 `auth` 텍스트 출력은 key 를 다시 보여주지 않는다. 자동화가 필요할 때만 `--format json` 또는 `--format shell` 을 사용한다.
|
||||
- 이 스킬은 read/search/retrieve 전용이다.
|
||||
- 메시지 전송과 삭제는 지원하지 않는다.
|
||||
- DB 내부 구조, auth cache, decryption material은 직접 다루지 않는다.
|
||||
- 기존 설치 이름은 `kakaotalk-mac`이지만 실행 표면은 `katok`이다.
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -11,10 +11,10 @@
|
|||
"build": "npm run build --workspaces --if-present",
|
||||
"build:manus-bundle": "node scripts/build-manus-bundle.js",
|
||||
"generate:plugin-manifest": "node scripts/generate-plugin-manifest.js",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py biz-health-check/scripts/biz_health_check.py nts-tax-delinquency/scripts/nts_tax_delinquency.py localdata-business-status/scripts/localdata_business_status.py g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py fsc-corporate-info/scripts/fsc_corporate_info.py national-pension-workplace/scripts/national_pension_workplace.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/kakaotalk_mac.py scripts/test_kakaotalk_mac.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py kakaotalk-mac/scripts/kakaotalk_mac.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
|
||||
"lint": "node --check scripts/skill-docs.test.js scripts/korean_character_count.js scripts/test_korean_character_count.js scripts/korean_middle_korean.js scripts/test_korean_middle_korean.js scripts/build-manus-bundle.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/generate-plugin-manifest.js scripts/test_generate_plugin_manifest.js && python3 -m py_compile scripts/k_skill_cleaner.py scripts/test_k_skill_cleaner.py corporate-registration-consulting/scripts/fill_official_hwp.py k-skill-cleaner/scripts/k_skill_cleaner.py scripts/fine_dust.py scripts/test_fine_dust.py scripts/ktx_booking.py scripts/test_ktx_booking.py scripts/srt_booking.py scripts/srt_seats.py scripts/srt_booking_test_support.py scripts/test_srt_booking.py scripts/test_srt_seats.py scripts/sillok_search.py scripts/test_sillok_search.py scripts/korean_spell_check.py scripts/test_korean_spell_check.py scripts/patent_search.py scripts/test_patent_search.py scripts/mfds_drug_safety.py scripts/test_mfds_drug_safety.py scripts/nts_business_registration.py scripts/test_nts_business_registration.py scripts/mfds_food_safety.py scripts/test_mfds_food_safety.py scripts/zipcode_search.py scripts/test_zipcode_search.py scripts/subway_lost_property.py scripts/test_subway_lost_property.py scripts/geeknews_search.py scripts/test_geeknews_search.py nts-business-registration/scripts/nts_business_registration.py biz-health-check/scripts/biz_health_check.py nts-tax-delinquency/scripts/nts_tax_delinquency.py localdata-business-status/scripts/localdata_business_status.py g2b-sanctioned-supplier/scripts/g2b_sanctioned_supplier.py fsc-corporate-info/scripts/fsc_corporate_info.py national-pension-workplace/scripts/national_pension_workplace.py scripts/test_naver_blog_search.py scripts/test_korean_slang_writing.py scripts/test_coupang_partners_mcp_wrapper.py scripts/test_ohou_today_deal.py scripts/ticket_availability.py scripts/test_ticket_availability.py scripts/test_danawa_price_search.py ticket-availability/scripts/ticket_availability.py coupang-product-search/scripts/coupang_partners_mcp.py ohou-today-deal/scripts/ohou_today_deal.py naver-blog-research/scripts/_naver_http.py naver-blog-research/scripts/naver_search.py naver-blog-research/scripts/naver_read.py naver-blog-research/scripts/naver_download_images.py korean-slang-writing/scripts/_slang_http.py korean-slang-writing/scripts/slang_search.py korean-slang-writing/scripts/slang_lookup.py korean-scholarship-search/scripts/scholarship_filter.py korean-scholarship-search/scripts/test_scholarship_filter.py korean-scholarship-search/scripts/university_search_plan.py seoul-bike/scripts/seoul_bike.py scripts/test_seoul_bike.py danawa-price-search/scripts/danawa_search.py kosis-stats/scripts/run_kosis_stats.py kosis-stats/tests/test_run_kosis_stats.py kstartup-search/scripts/run_kstartup.py kstartup-search/tests/test_run_kstartup.py intercity-bus-booking/scripts/intercity_bus_search.py daangn-used-goods-search/scripts/daangn_used_goods.py daangn-realty-search/scripts/daangn_realty.py daangn-jobs-search/scripts/daangn_jobs.py daangn-cars-search/scripts/daangn_cars.py foresttrip-vacancy/scripts/run_foresttrip_vacancy.py foresttrip-vacancy/tests/test_run_foresttrip_vacancy.py && npm run lint --workspaces --if-present && ./scripts/validate-skills.sh && node scripts/generate-plugin-manifest.js --check",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"prepare:python-test-env": "python3 -m venv .cache/python-test-venv && ./.cache/python-test-venv/bin/python -m pip install --quiet beautifulsoup4",
|
||||
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_srt_booking scripts.test_srt_seats scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_kakaotalk_mac scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"test": "npm run prepare:python-test-env && node --test scripts/skill-docs.test.js scripts/test_korean_character_count.js scripts/test_korean_middle_korean.js scripts/test_build_manus_bundle.js scripts/workflow-actions.test.js scripts/test_generate_plugin_manifest.js && PYTHONPATH=.:scripts ./.cache/python-test-venv/bin/python -m unittest scripts.test_k_skill_cleaner scripts.test_fine_dust scripts.test_ktx_booking scripts.test_srt_booking scripts.test_srt_seats scripts.test_sillok_search scripts.test_korean_spell_check scripts.test_patent_search scripts.test_mfds_drug_safety scripts.test_nts_business_registration scripts.test_mfds_food_safety scripts.test_zipcode_search scripts.test_subway_lost_property scripts.test_geeknews_search scripts.test_naver_blog_search scripts.test_korean_slang_writing scripts.test_coupang_partners_mcp_wrapper scripts.test_ohou_today_deal scripts.test_ticket_availability scripts.test_seoul_bike scripts.test_danawa_price_search && PYTHONPATH=.:scripts:korean-scholarship-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s korean-scholarship-search/scripts -p 'test_scholarship_filter.py' && PYTHONPATH=.:scripts:kosis-stats/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kosis-stats/tests -p 'test_run_kosis_stats.py' && PYTHONPATH=.:scripts:kstartup-search/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s kstartup-search/tests -p 'test_run_kstartup.py' && PYTHONPATH=.:foresttrip-vacancy/scripts ./.cache/python-test-venv/bin/python -m unittest discover -s foresttrip-vacancy/tests -p 'test_run_foresttrip_vacancy.py' && npm run test --workspaces --if-present && ./scripts/validate-skills.sh",
|
||||
"pack:dry-run": "npm pack --workspace k-lotto --dry-run && npm pack --workspace daiso-product-search --dry-run && npm pack --workspace market-kurly-search --dry-run && npm pack --workspace kakao-bar-nearby --dry-run && npm pack --workspace cheap-gas-nearby --dry-run && npm pack --workspace public-restroom-nearby --dry-run && npm pack --workspace parking-lot-search --dry-run && npm pack --workspace court-auction-notice-search --dry-run && npm pack --workspace donation-place-search --dry-run && npm pack --workspace gongsijiga-search --dry-run && npm pack --workspace kbl-results --dry-run && npm pack --workspace kleague-results --dry-run && npm pack --workspace lck-analytics --dry-run && npm pack --workspace toss-securities --dry-run && npm pack --workspace hipass-receipt --dry-run && npm pack --workspace used-car-price-search --dry-run && npm pack --workspace k-skill-rhwp --dry-run && npm pack --workspace korean-marathon-schedule --dry-run && npm pack --workspace gangnamunni-clinic-search --dry-run && npm pack --workspace daishin-report-search --dry-run && npm pack --workspace sh-notice-search --dry-run && npm pack --workspace emergency-room-beds --dry-run && npm pack --workspace local-election-candidate-search --dry-run",
|
||||
"ci": "npm run lint && npm run typecheck && npm run test && npm run pack:dry-run",
|
||||
"version-packages": "changeset version",
|
||||
|
|
|
|||
|
|
@ -1,11 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
_BUNDLED_HELPER = Path(__file__).resolve().parent.parent / "kakaotalk-mac" / "scripts" / "kakaotalk_mac.py"
|
||||
|
||||
if not _BUNDLED_HELPER.exists(): # pragma: no cover - defensive import guard
|
||||
raise FileNotFoundError(f"Bundled KakaoTalk helper not found: {_BUNDLED_HELPER}")
|
||||
|
||||
exec(compile(_BUNDLED_HELPER.read_text(encoding="utf-8"), str(_BUNDLED_HELPER), "exec"), globals())
|
||||
|
|
@ -311,8 +311,8 @@ test("repository docs advertise the kakaotalk-mac skill", () => {
|
|||
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
|
||||
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
|
||||
assert.match(readme, /\| 카카오톡 Mac CLI \|/);
|
||||
assert.match(readme, /\[카카오톡 Mac CLI\]\(docs\/features\/kakaotalk-mac\.md\)/);
|
||||
assert.match(readme, /\| 카카오톡 Mac 아카이브 검색 \|/);
|
||||
assert.match(readme, /\[카카오톡 Mac 아카이브 검색\]\(docs\/features\/kakaotalk-mac\.md\)/);
|
||||
assert.match(install, /--skill kakaotalk-mac/);
|
||||
});
|
||||
|
||||
|
|
@ -634,34 +634,42 @@ test("hosted proxy docs keep self-host overrides inactive and demonstrate resolv
|
|||
}
|
||||
});
|
||||
|
||||
test("kakaotalk-mac skill documents safe macOS kakaocli usage", () => {
|
||||
test("kakaotalk-mac skill documents katok archive search usage", () => {
|
||||
const skillPath = path.join(repoRoot, "kakaotalk-mac", "SKILL.md");
|
||||
const helperPath = path.join(repoRoot, "scripts", "kakaotalk_mac.py");
|
||||
const featureDoc = read(path.join("docs", "features", "kakaotalk-mac.md"));
|
||||
const featureDocPath = path.join(repoRoot, "docs", "features", "kakaotalk-mac.md");
|
||||
|
||||
assert.ok(fs.existsSync(skillPath), "expected kakaotalk-mac/SKILL.md to exist");
|
||||
assert.ok(fs.existsSync(helperPath), "expected scripts/kakaotalk_mac.py to exist");
|
||||
assert.ok(fs.existsSync(featureDocPath), "expected docs/features/kakaotalk-mac.md to exist");
|
||||
|
||||
const skill = read(path.join("kakaotalk-mac", "SKILL.md"));
|
||||
const featureDoc = read(path.join("docs", "features", "kakaotalk-mac.md"));
|
||||
|
||||
assert.match(skill, /^name: kakaotalk-mac$/m);
|
||||
assert.match(skill, /kakaocli/);
|
||||
assert.match(skill, /macOS/i);
|
||||
assert.match(skill, /KakaoTalk/i);
|
||||
assert.match(skill, /Full Disk Access/i);
|
||||
assert.match(skill, /Accessibility/i);
|
||||
assert.match(skill, /--me/);
|
||||
assert.match(skill, /confirm before sending/i);
|
||||
|
||||
for (const doc of [skill, featureDoc]) {
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py auth/);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py chats --limit 10 --json/);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py messages --chat/);
|
||||
assert.match(doc, /python3 scripts\/kakaotalk_mac\.py search/);
|
||||
assert.match(doc, /user_id 자동 감지 실패|SHA-512|DESIGNATEDFRIENDSREVISION/i);
|
||||
assert.match(doc, /cache|캐시/);
|
||||
assert.match(doc, /read-only|읽기 전용/i);
|
||||
assert.doesNotMatch(doc, /`query`/);
|
||||
assert.match(doc, /\bkatok\b/);
|
||||
assert.match(doc, /macOS/i);
|
||||
assert.match(doc, /KakaoTalk/i);
|
||||
assert.match(doc, /Full Disk Access/i);
|
||||
assert.match(doc, /katok doctor --json/);
|
||||
assert.match(doc, /katok permissions macos/);
|
||||
assert.match(doc, /katok sync --source macos --json/);
|
||||
assert.match(doc, /katok index --json/);
|
||||
assert.match(doc, /katok search keyword/);
|
||||
assert.match(doc, /katok search bm25/);
|
||||
assert.match(doc, /katok search semantic/);
|
||||
assert.match(doc, /katok chunk get/);
|
||||
assert.match(doc, /katok chunk context/);
|
||||
assert.match(doc, /katok chunk parent/);
|
||||
assert.match(doc, /(no|never|do not|don't|not).{0,80}((direct|raw).{0,40}(DB|database).{0,40}read|directly read.{0,40}(DB|database))/i);
|
||||
assert.match(doc, /(no|never|do not|don't|not).{0,80}(auth|authentication).{0,40}caches?/i);
|
||||
assert.match(doc, /(no|never|do not|don't|not).{0,80}decryption material/i);
|
||||
assert.doesNotMatch(doc, /kakaocli/);
|
||||
assert.doesNotMatch(doc, /python3 scripts\/kakaotalk_mac\.py/);
|
||||
assert.doesNotMatch(doc, /send --me/);
|
||||
assert.doesNotMatch(doc, /delete-last/);
|
||||
assert.doesNotMatch(doc, /confirm before sending/i);
|
||||
assert.doesNotMatch(doc, /SQLCipher key/i);
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -3992,7 +4000,7 @@ test("k-skill-rhwp package ships CLI bin, WASM-init shim, and minor semver chang
|
|||
const README_SKILL_NAME_COLUMN_MAPPING = [
|
||||
["SRT 예매", "srt-booking"],
|
||||
["KTX 예매", "ktx-booking"],
|
||||
["카카오톡 Mac CLI", "kakaotalk-mac"],
|
||||
["카카오톡 Mac 아카이브 검색", "kakaotalk-mac"],
|
||||
["서울 지하철 도착정보 조회", "seoul-subway-arrival"],
|
||||
["지하철 분실물 조회", "subway-lost-property"],
|
||||
["긱뉴스 조회", "geeknews-search"],
|
||||
|
|
|
|||
|
|
@ -1,473 +0,0 @@
|
|||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import json
|
||||
import io
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import scripts.kakaotalk_mac as kakaotalk_mac
|
||||
|
||||
|
||||
def sha512_hex(value: int) -> str:
|
||||
return hashlib.sha512(str(value).encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def make_resolved_auth(
|
||||
*,
|
||||
user_id: int = 123,
|
||||
uuid: str = "uuid",
|
||||
database_path: Path | None = None,
|
||||
database_name: str = "db-name",
|
||||
key: str = "super-secret",
|
||||
source: str = "cache",
|
||||
) -> kakaotalk_mac.ResolvedAuth:
|
||||
return kakaotalk_mac.ResolvedAuth(
|
||||
user_id=user_id,
|
||||
uuid=uuid,
|
||||
database_path=database_path or Path("/tmp/kakaotalk.db"),
|
||||
database_name=database_name,
|
||||
key=key,
|
||||
source=source,
|
||||
)
|
||||
|
||||
|
||||
class KakaoTalkMacHelperTests(unittest.TestCase):
|
||||
def test_parse_plist_xml_extracts_candidates_and_active_hash(self) -> None:
|
||||
active_hash = sha512_hex(123456)
|
||||
xml_text = f"""<?xml version="1.0" encoding="UTF-8"?>
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>AlertKakaoIDsList</key>
|
||||
<array>
|
||||
<integer>111</integer>
|
||||
<integer>222</integer>
|
||||
</array>
|
||||
<key>userId</key>
|
||||
<integer>333</integer>
|
||||
<key>DESIGNATEDFRIENDSREVISION:{active_hash}</key>
|
||||
<integer>5</integer>
|
||||
</dict>
|
||||
</plist>
|
||||
"""
|
||||
|
||||
parsed = kakaotalk_mac.parse_plist_xml(xml_text)
|
||||
|
||||
self.assertEqual(parsed["AlertKakaoIDsList"], [111, 222])
|
||||
self.assertEqual(kakaotalk_mac.collect_candidate_user_ids(parsed), [333, 111, 222])
|
||||
self.assertEqual(kakaotalk_mac.find_active_account_hash(parsed), active_hash)
|
||||
|
||||
def test_discover_database_files_filters_hex_names(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
root = Path(tempdir)
|
||||
expected = [
|
||||
root / ("a" * 78),
|
||||
root / ("b" * 78 + ".db"),
|
||||
]
|
||||
for path in expected:
|
||||
path.write_text("", encoding="utf-8")
|
||||
(root / ("c" * 40)).write_text("", encoding="utf-8")
|
||||
(root / ("d" * 78 + "-wal")).write_text("", encoding="utf-8")
|
||||
|
||||
discovered = kakaotalk_mac.discover_database_files(root)
|
||||
|
||||
self.assertEqual(discovered, expected)
|
||||
|
||||
def test_recover_user_id_from_sha512_supports_single_worker_search(self) -> None:
|
||||
target_user_id = 123456
|
||||
recovered = kakaotalk_mac.recover_user_id_from_sha512(
|
||||
sha512_hex(target_user_id),
|
||||
max_user_id=200000,
|
||||
workers=1,
|
||||
chunk_size=5000,
|
||||
)
|
||||
|
||||
self.assertEqual(recovered, target_user_id)
|
||||
|
||||
def test_resolve_auth_retries_with_hash_recovered_user_id_and_caches_result(self) -> None:
|
||||
target_user_id = 654321
|
||||
active_hash = sha512_hex(target_user_id)
|
||||
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
verification_calls: list[int] = []
|
||||
|
||||
state = kakaotalk_mac.DetectionState(
|
||||
uuid="42C34717-27C3-538C-81E4-8B568287C7A0",
|
||||
candidate_user_ids=[111, 222],
|
||||
active_account_hash=active_hash,
|
||||
database_files=[database_path],
|
||||
)
|
||||
|
||||
def verify(candidate: kakaotalk_mac.ResolvedAuth) -> bool:
|
||||
verification_calls.append(candidate.user_id)
|
||||
return candidate.user_id == target_user_id
|
||||
|
||||
resolved = kakaotalk_mac.resolve_auth_state(
|
||||
state,
|
||||
verify_access=verify,
|
||||
cache_path=cache_path,
|
||||
max_user_id=700000,
|
||||
workers=1,
|
||||
chunk_size=10000,
|
||||
)
|
||||
|
||||
cache_payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
||||
|
||||
self.assertEqual(verification_calls, [111, 222, target_user_id])
|
||||
self.assertEqual(resolved.user_id, target_user_id)
|
||||
self.assertEqual(resolved.database_path, database_path)
|
||||
self.assertEqual(cache_payload["user_id"], target_user_id)
|
||||
self.assertEqual(cache_payload["database_path"], str(database_path))
|
||||
|
||||
def test_load_cached_auth_treats_corrupt_json_as_cache_miss(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
cache_path.write_text("{bad json\n", encoding="utf-8")
|
||||
|
||||
self.assertIsNone(kakaotalk_mac.load_cached_auth(cache_path))
|
||||
|
||||
def test_resolve_auth_reuses_detection_when_cache_is_corrupt(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
cache_path.write_text("{bad json\n", encoding="utf-8")
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
resolved = make_resolved_auth(database_path=database_path, source="hash-recovery")
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=resolved) as resolve_state,
|
||||
):
|
||||
cached = kakaotalk_mac.resolve_auth(
|
||||
refresh=False,
|
||||
cache_path=cache_path,
|
||||
user_id_override=None,
|
||||
uuid_override=None,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
self.assertEqual(cached, resolved)
|
||||
collect_state.assert_called_once_with(None)
|
||||
resolve_state.assert_called_once()
|
||||
|
||||
def test_resolve_auth_bypasses_cache_when_user_id_override_is_supplied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
persistable = make_resolved_auth(database_path=database_path, source="cache")
|
||||
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
|
||||
override_result = make_resolved_auth(user_id=999, database_path=database_path, source="candidate")
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
|
||||
):
|
||||
resolved = kakaotalk_mac.resolve_auth(
|
||||
refresh=False,
|
||||
cache_path=cache_path,
|
||||
user_id_override=999,
|
||||
uuid_override=None,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, override_result)
|
||||
collect_state.assert_called_once_with(None)
|
||||
resolve_state.assert_called_once_with(
|
||||
mock.sentinel.state,
|
||||
verify_access=kakaotalk_mac.verify_database_access,
|
||||
cache_path=cache_path,
|
||||
user_id_override=999,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
def test_resolve_auth_bypasses_cache_when_uuid_override_is_supplied(self) -> None:
|
||||
with tempfile.TemporaryDirectory() as tempdir:
|
||||
cache_path = Path(tempdir) / "auth-cache.json"
|
||||
database_path = Path(tempdir) / "kakaotalk.db"
|
||||
database_path.write_text("", encoding="utf-8")
|
||||
persistable = make_resolved_auth(database_path=database_path, source="cache")
|
||||
kakaotalk_mac.persist_auth_cache(persistable, cache_path)
|
||||
override_result = make_resolved_auth(uuid="override-uuid", database_path=database_path, source="candidate")
|
||||
|
||||
with (
|
||||
mock.patch.object(kakaotalk_mac, "collect_detection_state", return_value=mock.sentinel.state) as collect_state,
|
||||
mock.patch.object(kakaotalk_mac, "resolve_auth_state", return_value=override_result) as resolve_state,
|
||||
):
|
||||
resolved = kakaotalk_mac.resolve_auth(
|
||||
refresh=False,
|
||||
cache_path=cache_path,
|
||||
user_id_override=None,
|
||||
uuid_override="override-uuid",
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
self.assertEqual(resolved, override_result)
|
||||
collect_state.assert_called_once_with("override-uuid")
|
||||
resolve_state.assert_called_once_with(
|
||||
mock.sentinel.state,
|
||||
verify_access=kakaotalk_mac.verify_database_access,
|
||||
cache_path=cache_path,
|
||||
user_id_override=None,
|
||||
max_user_id=1000,
|
||||
workers=1,
|
||||
chunk_size=100,
|
||||
)
|
||||
|
||||
def test_render_auth_text_redacts_key_material(self) -> None:
|
||||
resolved = make_resolved_auth(key="super-secret-key", source="hash-recovery")
|
||||
|
||||
rendered = kakaotalk_mac.render_auth(resolved, output_format="text", cache_path=Path("/tmp/cache.json"))
|
||||
|
||||
self.assertNotIn("super-secret-key", rendered)
|
||||
self.assertNotIn("--key", rendered)
|
||||
self.assertIn("python3 scripts/kakaotalk_mac.py chats --limit 10 --json", rendered)
|
||||
|
||||
def test_build_passthrough_command_rejects_non_read_only_command(self) -> None:
|
||||
auth = make_resolved_auth()
|
||||
|
||||
with self.assertRaises(kakaotalk_mac.AuthResolutionError):
|
||||
kakaotalk_mac.build_passthrough_command("query", auth, ["DELETE FROM chat_logs"])
|
||||
|
||||
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", "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()
|
||||
|
||||
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
|
||||
parser.parse_args(["auth", "--max-user-id", "-1"])
|
||||
|
||||
self.assertEqual(exit_context.exception.code, 2)
|
||||
self.assertIn("must be non-negative", stderr.getvalue())
|
||||
|
||||
def test_build_parser_rejects_non_positive_chunk_size(self) -> None:
|
||||
parser = kakaotalk_mac.build_parser()
|
||||
stderr = io.StringIO()
|
||||
|
||||
with self.assertRaises(SystemExit) as exit_context, mock.patch("sys.stderr", stderr):
|
||||
parser.parse_args(["auth", "--chunk-size", "0"])
|
||||
|
||||
self.assertEqual(exit_context.exception.code, 2)
|
||||
self.assertIn("must be positive", stderr.getvalue())
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Add table
Add a link
Reference in a new issue