Fix chat lookup, naming and send targeting for current KakaoTalk builds - #25
Open
altsang wants to merge 11 commits into
Open
Fix chat lookup, naming and send targeting for current KakaoTalk builds#25altsang wants to merge 11 commits into
altsang wants to merge 11 commits into
Conversation
`AXHelpers.findChatRow` located the chat name by AXIdentifier "_NS:18". Those
identifiers are AppKit internals (`_NS:` plus a nib-encoding ordinal) and shift
between KakaoTalk builds. On current versions the name label is "_NS:40" and
"_NS:18" is the "badge openchat room" AXImage — and since the lookup also
required role AXStaticText, it matched nothing at all.
The failure is silent and total:
- `kakaocli send <chat>` raises chatNotFound for every chat, because
findChatRow returns nil no matter what name is passed.
- `kakaocli harvest` records every chat as "(unknown)". On my install that
was 43 of 43.
Match on structure instead, which has held across builds: within the row's
AXCell the name is the first AXStaticText. The optional openchat badge ahead of
it is an AXImage; the unread "Count Label" and the timestamp both follow it.
Verified against a live `kakaocli inspect --depth 8` dump: 43 name labels at
"_NS:40", and every "_NS:18" in the tree is an AXImage.
ChatHarvester paired UI row i with database chat i, commented as "ordered by lastUpdatedAt DESC (matches UI order)". It doesn't match: chats the user has filed under "Silent Chatroom" are collapsed into a single folder row and are absent from the top-level list. On my install the UI shows 43 rows against 60 database chats, so every pairing after the first hidden chat was wrong. This was masked by the "_NS:18" bug in the previous commit — every name came out "(unknown)", so nothing was written. With the lookup fixed, harvest would have confidently written real names onto the wrong chat ids, which is worse than the failure it replaced. Pair by identity instead, in two passes. First by name, for chats the database can already title. Then, for the rows harvest actually exists to name, by the row's last-message preview against that chat's most recent message; the UI truncates previews, so this is a prefix match, and a prefix matching more than one chat is rejected rather than guessed. Rows matching nothing are skipped with a note. The asymmetry is deliberate: a missed row leaves a chat with the name it already had, while a wrong guess corrupts a real chat's identity. Measured on my install, this pairs 24 rows with zero disagreements against names the database independently knows, and resolves 4 of the 6 untitled rooms.
Chats are hidden from the top-level list by folders generally (the NTChatFolder table) and by Silent Chatroom, not just the latter. The original note named only Silent Chatroom and understated the reach: on my install 17 of 59 chats sit outside the top-level list, and send cannot reach any of them.
The chat list owner confirmed which chats are filed under Silent Chatroom, and the count closes: 16 chats = 14 muted plus 2 that are not. So membership cannot be read from the database — pushAlert=0 finds only 14 of the 16 — which matters for anyone hoping to detect unreachable chats without driving the UI.
… does harvest only called ensureReady() for .notRunning or .loginScreen, so a running KakaoTalk whose main window has been closed — which reports .loggedIn — skipped ensureWindowVisible() and fell straight into `throw noWindows`. KakaoAutomator.sendMessage has always called it unconditionally. This is the right shape but it does not by itself make the windowless case work. On my install, detectState() returns .unknown for a running, logged-in KakaoTalk with no open window: AXHelpers.windows() comes back non-empty but contains no role AXWindow elements (nested AXApplication and AXMenuBar entries), so realWindows is empty, checkStatusBarMenu() can't classify it, and the final `windows.isEmpty` guard is false — leaving .unknown, which ensureReady() throws on. So harvest now fails with "unrecognized state" instead of "noWindows" in that situation. Same outcome, more accurate diagnosis; the remaining fix belongs in detectState()/checkStatusBarMenu() and I'd rather not guess at the status-bar handling blind. Filed here so it's visible.
altsang
force-pushed
the
fix/chat-row-name-ax-identifier
branch
from
August 8, 2026 01:00
73957a7 to
96cd721
Compare
sendMessage could deliver a message to the wrong person. findChatRow returns an AXUIElement, but activating it is positional: scrollRowToVisible + doubleClickElement click at the row's screen coordinates. The chat list re-sorts every time a message arrives — I watched a chat move from row 7 to row 3 between two dumps taken minutes apart. So between matching the row and clicking it, a different chat can slide under those coordinates and open instead. Nothing downstream caught it. Step 7 accepts *any* window that isn't the main window, and step 9 types the message into it and presses Return. There was no comparison between the chat asked for and the chat opened, at any point. I hit the positional version of this by accident with `inspect --open-chat`, which double-clicks a matched row: the first click entered a folder and the second, at the same coordinates, opened the folder's most recent chat. A send would have gone to that chat. The window title is the chat name, so verify it and abort before typing. Delivering to the wrong recipient can't be undone, which makes this worth a check rather than an assumption. Matching is substring in both directions, the same rule findChatRow uses, so titles carrying a member count still match. New error case reports both names and states that nothing was sent.
`chats` considered only NTChatRoom.chatName and the direct-chat partner, so
every group chat and every open chat came back "(unknown)" — 9 of 60 rooms on
my install, all indistinguishable from one another. That also makes them
unaddressable: `send` takes a chat by name.
The name is assembled from four more places. Taking them in this order
reproduces the desktop app's list exactly — verified against a live
accessibility dump of the chat list, where every visible row matched and the
only names produced that the app doesn't show are the ones it hides behind its
"Silent Chatroom" folder:
1. NTChatRoom.chatName
2. NTChatMeta.content where type = 3 — the room title, and where a renamed
group chat actually keeps its name
3. NTOpenLink.linkName via linkId — open chats are named by their link and
nothing on the chat row carries it
4. the other participant, for a direct chat
5. otherwise the member names joined with ", ", which is how KakaoTalk labels
an unnamed group chat. Self excluded, the rest sorted by user id, which is
the order the app shows them in.
displayMemberIds is a binary plist of user ids; Row gains a blob accessor and
user display names are resolved once per reader rather than per row.
chatId = -1 is now excluded. It is a sentinel with no members and no name, not
a chat, and the app doesn't list it either.
Result on my install: 9 unnamed rooms down to 1, and that one is a room whose
only member is me, which the app also hides.
KakaoTalk collapses chats filed under "Silent Chatroom" into a single folder row, so they have no row in the top-level chat list and findChatRow cannot reach them — `send` fails with a bare chatNotFound that says nothing about why. On my install that is 16 of 59 chats. Nothing on the machine records that grouping. I checked every NTChatRoom column (pushAlert = 0 catches only the muted subset, 14 of the 16; hidden, favorite and pin don't separate them; extra is identical for folder members and visible chats), all three NTChatFolder rows, all 171 NTSetting keys, NTChatMeta, NTChatContext, and the container plists. It isn't there. So it has to be observed: compare the database against the visible list. A harvest already stands in exactly the right place, so it now records inTopLevelList for every chat — including the ones it never saw, since absence from the list is the whole signal — and MetadataStore persists it with the time it was checked. sendMessage consults that record only *after* findChatRow misses, never before. Checking first would let stale metadata block a send that would have worked, as a chat can be moved out of a folder at any time with no notification. Used this way it can only ever explain a failure that already happened, via a new chatInFolder error that names the cause and the fix. `chats --json` gains in_top_level_list, omitted rather than null when no harvest has run — absent means unknown, which is not the same as reachable. The plain-text listing is deliberately unchanged; downstream parsers key off its exact shape. Two fixes were needed to make this correct: - Harvest paired UI rows using its own rawQuery names, which know only chatName and the direct partner, so open chats failed to pair and were recorded as unreachable while plainly sitting in the list. It now pairs on DatabaseReader.chats() names. Pairing went from 39/43 to 42/43 — the one unpaired row is the folder row itself, which is not a chat — and the unreachable count from a wrong 21 to a correct 17. - MetadataStore.init decoded with a default JSONDecoder while save() encoded dates as .iso8601, so every load threw on the first lastHarvested and fell back to an empty store. metadata.json has been write-only since it was added, which is also why `harvest --dry-run` never printed its "(metadata: …)" annotations. Verified end to end: 42 reachable, 17 not, 0 unknown, and a send to a chat in the folder now explains itself instead of failing blankly.
Chats filed under a folder ("Silent Chatroom") are collapsed into one folder
row, so findChatRow could not see them and `send` failed with no recourse — 16
of 59 chats on my install, including the one I actually use.
They are still searchable, and the results render into the *same* AXTable with
the same row structure, so findChatRow works on them unchanged. sendMessage now
falls back to search when the top-level lookup misses.
Search is preferred over walking into folder rows: it needs no way to recognise
a folder (the only signal is its localised "N chatrooms" preview text), it
covers user-created folders and any future grouping for free, and it is undone
by one button rather than a navigation stack.
Three things this needed, each found by testing rather than reasoning:
- Opening must be idempotent. The Search button *toggles*, so pressing it while
a field is already showing closes the search instead. That failed about one
run in three. Reuse an open field, and poll for a new one rather than trusting
a fixed delay.
- Clearing must be verified, and AXPress on the field's own cancel button is
inert on this build — it silently did nothing and left the list filtered to a
single row. Pressing the Search toggle closes it reliably; the other steps are
kept as fallbacks and the result is checked.
- The lookup must clear any leftover filter *before* it runs. A filter left by
an earlier call reduces the list to its results, so findChatRow searches that
instead of the top-level list — which both hides chats that are really there
and makes a folder chat look like a top-level one. This masked a failure
during development: five consecutive runs appeared to succeed while actually
searching a one-row list.
Also raises the main window before any synthetic input: an open chat window
covers the search field, so coordinate-based steps would land on it.
inspect gains --search and --clear-search, which is how the above was worked
out and how the UI can be re-checked when KakaoTalk changes. --open-chat now
uses the same fallback and select-then-Enter path, with a double-click only as a
last resort, since Enter does nothing while focus sits in the search field.
Verified 5/5 on a chat inside the folder, each run reporting "via search" and
leaving the full 43-row list restored.
The text said a folder chat cannot be opened by automation. That stopped being true when sendMessage gained the search fallback — reaching this error now means search *also* failed to find the chat, which points at a name mismatch rather than at the folder. Says so, and keeps the note that only sending goes through the UI.
Both areas this PR touches are pure functions with no database or UI dependency, so they are cheap to test and were not covered at all. Chat naming asserts the fallback order and the two things found by experiment rather than reasoning: chatName must outrank the meta title (with them the other way round, a chat the app calls "Korean LLC for Junipera" came out as "D8 visa"), and group members sort by user id with self excluded (sorting the other way produced "Bob, Alice" where KakaoTalk shows "Alice, Bob"). Also covers the empty-string columns the database uses in place of NULL, and a room whose only member is you. The metadata round-trip is a regression test for a bug that was silent for the store's entire life: save() encoded dates as .iso8601 while init decoded with a default JSONDecoder, so every load threw on the first date and fell back to an empty store — nothing noticed because nothing round-tripped it. Verified the test fails (9 expectations) with the old decoder restored. MetadataStore gains an optional `path` so tests don't touch the user's real ~/.kakaocli/metadata.json. Production callers are unchanged.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On current KakaoTalk builds,
sendandharvestare both broken, and it'ssilent. This started as a one-line identifier fix and grew as each repair
uncovered the next problem underneath. Eleven commits, each self-contained.
1. The chat name lookup matches nothing
AXHelpers.findChatRowlocated a chat's label by AXIdentifier_NS:18. Thoseidentifiers are AppKit internals —
_NS:plus a nib-encoding ordinal — and theyshift between builds. On mine the label is
_NS:40, and_NS:18is the"badge openchat room"
AXImage. Both call sites also require roleAXStaticText, so they match nothing at all, with no error path:kakaocli send <chat>raiseschatNotFoundfor every chat.kakaocli harvestrecords every chat as(unknown)— 43 of 43 on mine.Fixed by matching on structure, which has been stable: inside the row's
AXCellthe name is the firstAXStaticText. The optional openchat badge aheadof it is an
AXImage; the unread count and timestamp follow it.2. Harvest pairs UI rows to chats by list position
ChatHarvesterpaired UI rowiwith database chati, commented as "matchesUI order". It doesn't — folders collapse chats out of the visible list, so on my
install 43 rows face 60 chats. Every pairing after the first hidden chat was
wrong.
This was masked by bug 1: names all came out
(unknown), so nothing waswritten. Fixing only the identifier would have made harvest write real names
onto wrong chat ids — worse than the failure it replaced. They belong together.
Now paired by identity: by name, then by last-message preview (prefix, since the
UI truncates), with ambiguity rejected rather than guessed. Rows matching
nothing are skipped and reported. A missed row leaves a chat's existing name
alone; a bad guess corrupts a real chat's identity.
3. Group and open chats have no name
chats()derived names fromchatNameand the direct-chat partner only, soevery group and open chat fell through to
(unknown)— 9 of 60, mutuallyindistinguishable, and unaddressable since
sendtakes a name.KakaoTalk assembles the name from four more places. This order reproduces the
desktop list exactly:
NTChatRoom.chatNameNTChatMeta.contentwheretype = 3— where a renamed group keeps its nameNTOpenLink.linkNamevialinkId— open chats; nothing on the chat row has it", "— self excluded, sorted by user iddisplayMemberIdsis a binary plist of user ids. Verified against a liveaccessibility dump:
(unknown)9 → 1, and zero desktop rows the tool can'tname. The remaining one is a room whose only member is me, which the app hides
too.
chatId = -1is now excluded — a sentinel, not a chat.4. A send could go to the wrong person
sendMessagematched a row, activated it, then typed and pressed Returnwithout checking which chat opened. Row activation is positional
(
scrollRowToVisible+doubleClickElement), and the chat list re-sorts onevery incoming message — I watched a chat move from row 7 to row 3 between two
dumps. Step 7 accepted any non-main window and step 9 typed into it.
I hit the positional version by accident with
inspect --open-chat: the firstclick of a double-click entered a folder and the second, at the same
coordinates, opened the folder's most recent chat. A send would have gone there.
The window title is the chat name, so it's now compared before typing, aborting
with a
wrongChatOpenederror that names both. Misdelivery can't be undone.5. Chats inside folders were unreachable
Chats filed under "Silent Chatroom" are collapsed into one folder row, so
findChatRowcouldn't see them — 16 of 59 on my install. They are stillsearchable, and results render into the same
AXTablewith the same rowstructure, so
findChatRowworks on them unchanged.sendMessagenow fallsback to search.
Search beats walking into folder rows: no folder recognition needed (the only
signal is a localised "N chatrooms" preview), it covers user folders and future
groupings for free, and one button undoes it.
Three things testing forced:
while the field shows closes the search. Failed ~1 run in 3.
AXPresson the field's cancel button isinert on this build; it silently left the list filtered to one row. The
Search toggle closes it reliably.
findChatRowsearches the filtered list, not the top-level one. This masked afailure during development where five consecutive runs "passed" while
searching a one-row list.
Verified 5/5 against a chat inside the folder, list restored each time.
6. Reachability is recorded, and explains a failure
Folder membership is stored nowhere on the machine. I checked every
NTChatRoomcolumn (pushAlert = 0catches only the muted subset, 14 of 16;hidden,favorite,pindon't separate them;extrais identical), allNTChatFolderrows, all 171NTSettingkeys,NTChatMeta,NTChatContext,and the container plists. So it's derived by difference — and a harvest already
stands in the right place, so it now records
inTopLevelListfor every chat,including ones it never saw, since absence is the signal.
sendMessageconsults it only after the lookup fails, never as a precheck:a chat can leave a folder at any time with no notification, so a precheck would
let stale state veto a send that would have worked.
chats --jsongainsin_top_level_list, omitted rather than null when unknown. The plain-textlisting is deliberately unchanged — downstream parsers key off its shape.
Also
harvestnow callsensureReady()unconditionally, assendMessagealwayshas. Related but not fixed:
detectState()returns.unknownfor arunning, logged-in KakaoTalk with no open window —
windows()comes backnon-empty but holds no
AXWindowelements, sorealWindowsis empty and thefinal
windows.isEmptyguard is false. I didn't want to guess at thestatus-bar handling blind; noted in the commit.
inspectgains--searchand--clear-search, which is how the search UIwas worked out and how it can be re-checked when KakaoTalk changes.
save()encoded dates as.iso8601whileinitdecoded with a defaultJSONDecoder, so every load threw and fell back to an empty store —metadata.jsonhas been write-only since it was added, which is also whyharvest --dry-runnever printed its "(metadata: …)" annotations. Confirmedthe test fails with the old decoder restored.
MetadataStoregains anoptional
pathso tests don't touch the real file.Caveats
Everything is verified against one install (KakaoTalk on macOS 14, English
UI). Two spots are locale-sensitive and I've flagged them in comments rather
than pretending otherwise: the folder row's "N chatrooms" preview, which the
harvest pairing skips, and nothing else depends on English strings.
Search can return several rows — querying a member's name also matches group
chats containing them.
findChatRowtakes the first substring match, so thetitle verification in §4 is what makes a wrong pick abort instead of misdeliver.
swift testpasses (12).