Skip to content

Fix chat lookup, naming and send targeting for current KakaoTalk builds - #25

Open
altsang wants to merge 11 commits into
silver-flight-group:mainfrom
altsang:fix/chat-row-name-ax-identifier
Open

Fix chat lookup, naming and send targeting for current KakaoTalk builds#25
altsang wants to merge 11 commits into
silver-flight-group:mainfrom
altsang:fix/chat-row-name-ax-identifier

Conversation

@altsang

@altsang altsang commented Aug 6, 2026

Copy link
Copy Markdown

On current KakaoTalk builds, send and harvest are both broken, and it's
silent. 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.findChatRow located a chat's label by AXIdentifier _NS:18. Those
identifiers are AppKit internals — _NS: plus a nib-encoding ordinal — and they
shift between builds. On mine the label is _NS:40, and _NS:18 is the
"badge openchat room" AXImage. Both call sites also require role
AXStaticText, so they match nothing at all, with no error path:

  • kakaocli send <chat> raises chatNotFound for every chat.
  • kakaocli harvest records every chat as (unknown) — 43 of 43 on mine.

Fixed by matching on structure, which has been stable: inside the row's
AXCell the name is the first AXStaticText. The optional openchat badge ahead
of it is an AXImage; the unread count and timestamp follow it.

2. Harvest pairs UI rows to chats by list position

ChatHarvester paired UI row i with database chat i, commented as "matches
UI 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 was
written. 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 from chatName and the direct-chat partner only, so
every group and open chat fell through to (unknown) — 9 of 60, mutually
indistinguishable, and unaddressable since send takes a name.

KakaoTalk assembles the name from four more places. This order reproduces the
desktop list exactly:

  1. NTChatRoom.chatName
  2. NTChatMeta.content where type = 3 — where a renamed group keeps its name
  3. NTOpenLink.linkName via linkId — open chats; nothing on the chat row has it
  4. the direct-chat partner
  5. otherwise member names joined ", " — self excluded, sorted by user id

displayMemberIds is a binary plist of user ids. Verified against a live
accessibility dump: (unknown) 9 → 1, and zero desktop rows the tool can't
name. The remaining one is a room whose only member is me, which the app hides
too. chatId = -1 is now excluded — a sentinel, not a chat.

4. A send could go to the wrong person

sendMessage matched a row, activated it, then typed and pressed Return
without checking which chat opened. Row activation is positional
(scrollRowToVisible + doubleClickElement), and the chat list re-sorts on
every 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 first
click 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 wrongChatOpened error 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
findChatRow couldn't see them — 16 of 59 on my install. They are still
searchable, and results render into the same AXTable with the same row
structure, so findChatRow works on them unchanged. sendMessage now falls
back 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:

  • Opening must be idempotent — the Search button toggles, so pressing it
    while the field shows closes the search. Failed ~1 run in 3.
  • Clearing must be verifiedAXPress on the field's cancel button is
    inert on this build; it silently left the list filtered to one row. The
    Search toggle closes it reliably.
  • A stale filter must be cleared before the lookup — otherwise
    findChatRow searches the filtered list, not the top-level one. This masked a
    failure 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
NTChatRoom column (pushAlert = 0 catches only the muted subset, 14 of 16;
hidden, favorite, pin don't separate them; extra is identical), all
NTChatFolder rows, all 171 NTSetting keys, 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 inTopLevelList for every chat,
including ones it never saw, since absence is the signal.

sendMessage consults 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 --json gains
in_top_level_list, omitted rather than null when unknown. The plain-text
listing is deliberately unchanged
— downstream parsers key off its shape.

Also

  • harvest now calls ensureReady() unconditionally, as sendMessage always
    has. Related but not fixed: detectState() returns .unknown for a
    running, logged-in KakaoTalk with no open window — windows() comes back
    non-empty but holds no AXWindow elements, so realWindows is empty and the
    final windows.isEmpty guard is false. I didn't want to guess at the
    status-bar handling blind; noted in the commit.
  • inspect gains --search and --clear-search, which is how the search UI
    was worked out and how it can be re-checked when KakaoTalk changes.
  • Tests for the naming chain and a regression test for metadata persistence:
    save() encoded dates as .iso8601 while init decoded with a default
    JSONDecoder, so every load threw 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. Confirmed
    the test fails with the old decoder restored. MetadataStore gains an
    optional path so 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. findChatRow takes the first substring match, so the
title verification in §4 is what makes a wrong pick abort instead of misdeliver.

swift test passes (12).

altsang added 5 commits August 6, 2026 15:30
`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
altsang force-pushed the fix/chat-row-name-ax-identifier branch from 73957a7 to 96cd721 Compare August 8, 2026 01:00
altsang added 6 commits August 7, 2026 20:16
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.
@altsang altsang changed the title Fix chat row name lookup (send and harvest are broken on current KakaoTalk builds) Fix chat lookup, naming and send targeting for current KakaoTalk builds Aug 8, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant