Narrow KakaoTalk deletion to visible text proof

Constrain destructive KakaoTalk deletion to the proof the helper can actually establish: an outbound DB candidate whose normalized text maps to exactly one visible targetable UI bubble, with explicit confirmation success required.\n\nConstraint: KakaoTalk Accessibility does not expose a reliable DB message-id identity in this non-interactive automation path.\nRejected: Preserve [type] fallback targets or raw-text duplicate checks | they can bridge DB selection to a different visible UI bubble.\nRejected: Report deletion success after best-effort confirmation clicks | UI label drift could create false success.\nConfidence: high\nScope-risk: narrow\nDirective: Do not widen delete automation beyond the visible-bubble contract unless UI identity proof is added and tested.\nTested: PYTHONPATH=.:scripts python3 -m unittest scripts.test_kakaotalk_mac; python3 scripts/kakaotalk_mac.py delete --help; python3 scripts/kakaotalk_mac.py delete-last --help; git diff --check; npm run ci\nNot-tested: Live KakaoTalk/macOS Accessibility deletion was not executed in this non-interactive session.
This commit is contained in:
Jeffrey (Dongkyu) Kim 2026-05-15 04:02:19 +09:00
commit daf98ee292
4 changed files with 72 additions and 16 deletions

View file

@ -7,7 +7,7 @@
- 키워드로 전체 대화 검색
- 나와의 채팅으로 안전하게 테스트 전송
- 사용자 확인 후 특정 채팅방으로 메시지 전송
- 로컬 메시지 ID 또는 최근 보낸 메시지를 기준으로 UI 삭제 자동화
- 로컬 메시지 ID로 후보를 고른 뒤 현재 보이는 정확히 하나의 UI bubble 삭제 자동화
## 먼저 필요한 것
@ -82,7 +82,7 @@ helper `scripts/kakaotalk_mac.py` 는 그 얇은 read-only 어댑터 역할을
## 메시지 삭제
`delete` / `delete-last` 는 카카오톡 Mac UI의 삭제 메뉴를 Accessibility 로 자동화한다. 메시지 조회는 로컬 DB에서 하고, 실제 삭제는 UI에서 수행한다.
`delete` / `delete-last` 는 카카오톡 Mac UI의 삭제 메뉴를 Accessibility 로 자동화한다. 메시지 조회와 outbound 여부 검증은 로컬 DB에서 하고, 실제 삭제는 현재 보이는 UI bubble 에서 수행한다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "팀 공지방" --limit 20 --json
@ -92,7 +92,7 @@ python3 scripts/kakaotalk_mac.py delete-last "팀 공지방" --everyone
```
- `--everyone` 은 내가 보낸 메시지에만 허용된다.
- UI 삭제 단계는 활성 채팅방을 확인하고, 대화 transcript 영역에서 정규화된 텍스트가 정확히 하나의 visible message bubble 과 일치할 때만 진행한다. 메시지 텍스트가 보이지 않거나 같은 텍스트가 여러 개면 실패한다.
- 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 이다.
## 주의할 점

View file

@ -169,7 +169,7 @@ kakaocli send "채팅방 이름" "보낼 문장"
### 7. Delete a sent message only with explicit operator intent
삭제는 카카오톡 Mac UI(우클릭 → 삭제)를 접근성으로 자동화한다. 먼저 `messages --json` 으로 로컬 메시지 ID를 확인하고, 항상 `--dry-run` 으로 대상 채팅방/메시지를 확인한 뒤 실행한다. `--everyone` 은 내가 보낸 메시지에만 허용된다.
삭제는 카카오톡 Mac UI(우클릭 → 삭제)를 접근성으로 자동화한다. 먼저 `messages --json` 으로 로컬 메시지 ID와 보낸 메시지 여부를 확인하고, 항상 `--dry-run` 으로 대상 채팅방/메시지를 확인한 뒤 실행한다. `--everyone` 은 내가 보낸 메시지에만 허용된다.
```bash
python3 scripts/kakaotalk_mac.py messages --chat "채팅방 이름" --limit 20 --json
@ -182,8 +182,8 @@ 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 삭제 단계는 활성 채팅방을 확인하고, 대화 transcript 영역에서 정규화된 텍스트가 정확히 하나의 visible message bubble 과 일치할 때만 진행한다.
- 대상 메시지 텍스트가 보이지 않거나, 같은 텍스트가 여러 개 있거나, 활성 채팅방을 확인할 수 없으면 삭제 자동화는 실패한다.
- 메시지 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

View file

@ -628,14 +628,23 @@ def select_delete_target(
)
text = raw.get("text")
if not isinstance(text, str) or not text.strip():
text = f"[{raw.get('type', 'message')}]"
matching_text_ids = [_message_id(message) for message in messages if message.get("text") == 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 text. "
"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,
@ -645,6 +654,13 @@ def select_delete_target(
)
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):
@ -701,7 +717,6 @@ end normalizeText
set chatName to {_applescript_string(chat)}
set messageText to {_applescript_string(target.text)}
set messageTimestamp to {_applescript_string(target.timestamp or "")}
set normalizedChatName to normalizeText(chatName)
set normalizedMessageText to normalizeText(messageText)
set deleteLabels to {{{labels}}}
@ -828,12 +843,18 @@ tell application "System Events"
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
try
click button "Delete" of window 1
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()

View file

@ -367,7 +367,7 @@ class KakaoTalkMacHelperTests(unittest.TestCase):
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.assertIn("set messageTimestamp to", 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()
@ -412,7 +412,42 @@ class KakaoTalkMacHelperTests(unittest.TestCase):
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 text", str(context.exception))
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()