agent-messenger 2.12.1 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +18 -0
  4. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -0
  5. package/dist/src/platforms/kakaotalk/chat-classifier.js +29 -0
  6. package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -0
  7. package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
  8. package/dist/src/platforms/kakaotalk/cli.js +2 -1
  9. package/dist/src/platforms/kakaotalk/cli.js.map +1 -1
  10. package/dist/src/platforms/kakaotalk/client.d.ts +35 -1
  11. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  12. package/dist/src/platforms/kakaotalk/client.js +318 -15
  13. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  14. package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
  15. package/dist/src/platforms/kakaotalk/commands/chat.js +2 -1
  16. package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
  17. package/dist/src/platforms/kakaotalk/commands/index.d.ts +1 -0
  18. package/dist/src/platforms/kakaotalk/commands/index.d.ts.map +1 -1
  19. package/dist/src/platforms/kakaotalk/commands/index.js +1 -0
  20. package/dist/src/platforms/kakaotalk/commands/index.js.map +1 -1
  21. package/dist/src/platforms/kakaotalk/commands/member.d.ts +3 -0
  22. package/dist/src/platforms/kakaotalk/commands/member.d.ts.map +1 -0
  23. package/dist/src/platforms/kakaotalk/commands/member.js +22 -0
  24. package/dist/src/platforms/kakaotalk/commands/member.js.map +1 -0
  25. package/dist/src/platforms/kakaotalk/index.d.ts +4 -2
  26. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  27. package/dist/src/platforms/kakaotalk/index.js +2 -1
  28. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  29. package/dist/src/platforms/kakaotalk/listener.d.ts +4 -7
  30. package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
  31. package/dist/src/platforms/kakaotalk/listener.js +48 -74
  32. package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
  33. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +28 -0
  34. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  35. package/dist/src/platforms/kakaotalk/protocol/session.js +44 -0
  36. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  37. package/dist/src/platforms/kakaotalk/types.d.ts +37 -0
  38. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  39. package/dist/src/platforms/kakaotalk/types.js +17 -0
  40. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  41. package/dist/src/platforms/slackbot/client.d.ts +5 -0
  42. package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
  43. package/dist/src/platforms/slackbot/client.js +5 -0
  44. package/dist/src/platforms/slackbot/client.js.map +1 -1
  45. package/dist/src/tui/adapters/kakaotalk-adapter.js +3 -3
  46. package/dist/src/tui/adapters/kakaotalk-adapter.js.map +1 -1
  47. package/docs/content/docs/cli/kakaotalk.mdx +26 -1
  48. package/docs/content/docs/sdk/kakaotalk.mdx +45 -13
  49. package/docs/content/docs/sdk/slackbot.mdx +11 -0
  50. package/package.json +1 -1
  51. package/scripts/kakao-loco-capture.ts +466 -0
  52. package/skills/agent-channeltalk/SKILL.md +1 -1
  53. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  54. package/skills/agent-discord/SKILL.md +1 -1
  55. package/skills/agent-discordbot/SKILL.md +1 -1
  56. package/skills/agent-instagram/SKILL.md +1 -1
  57. package/skills/agent-kakaotalk/SKILL.md +30 -3
  58. package/skills/agent-kakaotalk/references/common-patterns.md +49 -1
  59. package/skills/agent-line/SKILL.md +1 -1
  60. package/skills/agent-slack/SKILL.md +1 -1
  61. package/skills/agent-slackbot/SKILL.md +1 -2
  62. package/skills/agent-teams/SKILL.md +1 -1
  63. package/skills/agent-telegram/SKILL.md +1 -1
  64. package/skills/agent-telegrambot/SKILL.md +1 -1
  65. package/skills/agent-webex/SKILL.md +1 -1
  66. package/skills/agent-wechatbot/SKILL.md +1 -1
  67. package/skills/agent-whatsapp/SKILL.md +1 -1
  68. package/skills/agent-whatsappbot/SKILL.md +1 -1
  69. package/src/platforms/kakaotalk/chat-classifier.test.ts +33 -0
  70. package/src/platforms/kakaotalk/chat-classifier.ts +31 -0
  71. package/src/platforms/kakaotalk/cli.ts +2 -1
  72. package/src/platforms/kakaotalk/client-listener-integration.test.ts +411 -0
  73. package/src/platforms/kakaotalk/client.test.ts +785 -1
  74. package/src/platforms/kakaotalk/client.ts +369 -18
  75. package/src/platforms/kakaotalk/commands/chat.ts +3 -1
  76. package/src/platforms/kakaotalk/commands/index.ts +1 -0
  77. package/src/platforms/kakaotalk/commands/member.test.ts +102 -0
  78. package/src/platforms/kakaotalk/commands/member.ts +32 -0
  79. package/src/platforms/kakaotalk/index.test.ts +5 -0
  80. package/src/platforms/kakaotalk/index.ts +4 -0
  81. package/src/platforms/kakaotalk/listener.test.ts +184 -149
  82. package/src/platforms/kakaotalk/listener.ts +51 -82
  83. package/src/platforms/kakaotalk/protocol/session.ts +44 -0
  84. package/src/platforms/kakaotalk/types.ts +39 -0
  85. package/src/platforms/slackbot/client.test.ts +67 -0
  86. package/src/platforms/slackbot/client.ts +17 -1
  87. package/src/tui/adapters/kakaotalk-adapter.ts +3 -3
@@ -9,8 +9,16 @@ import { warn } from '@/shared/utils/stderr'
9
9
 
10
10
  import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
11
11
  import { LocoSession } from './protocol/session'
12
- import type { ChatListResponse, LoginListResponse, SyncState } from './protocol/types'
13
- import type { KakaoChat, KakaoDeviceType, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
12
+ import type { ChatListResponse, LocoPacket, LoginListResponse, SyncState } from './protocol/types'
13
+ import type { KakaoChat, KakaoDeviceType, KakaoMember, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
14
+
15
+ export type KakaoSessionEvent =
16
+ | { type: 'connected'; userId: string }
17
+ | { type: 'disconnected' }
18
+ | { type: 'kicked'; reason: string }
19
+
20
+ export type KakaoPushHandler = (packet: LocoPacket) => void
21
+ export type KakaoSessionEventHandler = (event: KakaoSessionEvent) => void
14
22
 
15
23
  export class KakaoTalkError extends Error {
16
24
  code: string
@@ -29,6 +37,58 @@ interface SessionState {
29
37
  loginResult: LoginListResponse
30
38
  }
31
39
 
40
+ class MemberNameCache {
41
+ private byChatId = new Map<string, Map<number, string>>()
42
+
43
+ ingest(chatDatas: readonly ChatData[]): void {
44
+ for (const chat of chatDatas) {
45
+ const ids = chat.i as Array<{ low: number; high: number } | number> | undefined
46
+ const names = chat.k as string[] | undefined
47
+ if (!Array.isArray(ids) || !Array.isArray(names)) continue
48
+
49
+ const chatId = String(chat.c)
50
+ let map = this.byChatId.get(chatId)
51
+ if (!map) {
52
+ map = new Map()
53
+ this.byChatId.set(chatId, map)
54
+ }
55
+
56
+ const len = Math.min(ids.length, names.length)
57
+ for (let i = 0; i < len; i++) {
58
+ const numericId = toNumericUserId(ids[i])
59
+ if (numericId === null) continue
60
+ const name = names[i]
61
+ if (typeof name === 'string' && name.length > 0) {
62
+ map.set(numericId, name)
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ lookup(chatId: string, userId: number): string | null {
69
+ return this.byChatId.get(chatId)?.get(userId) ?? null
70
+ }
71
+
72
+ forget(chatId: string): void {
73
+ this.byChatId.delete(chatId)
74
+ }
75
+
76
+ clear(): void {
77
+ this.byChatId.clear()
78
+ }
79
+ }
80
+
81
+ function toNumericUserId(v: unknown): number | null {
82
+ if (typeof v === 'number') return Number.isFinite(v) ? v : null
83
+ if (v && typeof v === 'object' && 'low' in v && 'high' in v) {
84
+ const { low, high } = v as { low: number; high: number }
85
+ // chatDatas[].i entries are member user IDs. KakaoTalk user IDs fit in
86
+ // 53 bits — safe to flatten the BSON Long pair to a JS number for keying.
87
+ return (high >>> 0) * 0x100000000 + (low >>> 0)
88
+ }
89
+ return null
90
+ }
91
+
32
92
  function bsonToLong(v: unknown): Long | undefined {
33
93
  if (v && typeof v === 'object' && 'high' in v && 'low' in v) {
34
94
  const { high, low } = v as { high: number; low: number }
@@ -52,20 +112,39 @@ function parseLong(s: string): Long {
52
112
  return new Long(low, high)
53
113
  }
54
114
 
55
- function formatChat(chat: ChatData): KakaoChat {
115
+ function parseChatId(chatId: string): Long {
116
+ try {
117
+ return parseLong(chatId)
118
+ } catch (cause) {
119
+ throw new KakaoTalkError(`Invalid chatId: ${chatId}`, 'invalid_chat_id', { cause })
120
+ }
121
+ }
122
+
123
+ function parseUserId(userId: string): Long {
124
+ try {
125
+ return parseLong(userId)
126
+ } catch (cause) {
127
+ throw new KakaoTalkError(`Invalid userId: ${userId}`, 'invalid_user_id', { cause })
128
+ }
129
+ }
130
+
131
+ function formatChat(chat: ChatData, title: string | null, nameCache: MemberNameCache): KakaoChat {
56
132
  const memberNames = (chat.k ?? []) as string[]
57
133
  const lastLog = chat.l as Record<string, unknown> | null
58
134
  const displayName = memberNames.join(', ') || null
135
+ const chatId = String(chat.c)
59
136
 
60
137
  return {
61
- chat_id: String(chat.c),
138
+ chat_id: chatId,
62
139
  type: chat.t as number,
63
140
  display_name: displayName,
141
+ title,
64
142
  active_members: chat.a as number,
65
143
  unread_count: chat.n as number,
66
144
  last_message: lastLog
67
145
  ? {
68
146
  author_id: lastLog.authorId as number,
147
+ author_name: nameCache.lookup(chatId, lastLog.authorId as number),
69
148
  message: lastLog.message as string,
70
149
  sent_at: lastLog.sendAt as number,
71
150
  }
@@ -73,6 +152,50 @@ function formatChat(chat: ChatData): KakaoChat {
73
152
  }
74
153
  }
75
154
 
155
+ const META_TYPE_TITLE = 3
156
+
157
+ interface ChannelMetaEntry {
158
+ type?: number
159
+ content?: string
160
+ }
161
+
162
+ interface ChannelInfoResponse {
163
+ chatInfo?: {
164
+ chatMetas?: ChannelMetaEntry[]
165
+ }
166
+ }
167
+
168
+ function extractTitle(body: Record<string, unknown>): string | null {
169
+ const info = (body as ChannelInfoResponse).chatInfo
170
+ const metas = info?.chatMetas
171
+ if (!Array.isArray(metas)) return null
172
+
173
+ const titleMeta = metas.find((m) => m?.type === META_TYPE_TITLE)
174
+ const content = titleMeta?.content
175
+ return typeof content === 'string' && content.length > 0 ? content : null
176
+ }
177
+
178
+ interface OpenLinkInfoResponse {
179
+ ols?: Array<{ ln?: unknown }>
180
+ }
181
+
182
+ function extractOpenLinkName(body: Record<string, unknown>): string | null {
183
+ const ols = (body as OpenLinkInfoResponse).ols
184
+ if (!Array.isArray(ols) || ols.length === 0) return null
185
+ const ln = ols[0]?.ln
186
+ return typeof ln === 'string' && ln.length > 0 ? ln : null
187
+ }
188
+
189
+ const OPEN_CHAT_TYPES = new Set(['OM', 'OD'])
190
+
191
+ function isOpenChat(chat: ChatData): boolean {
192
+ return typeof chat.t === 'string' && OPEN_CHAT_TYPES.has(chat.t)
193
+ }
194
+
195
+ function getOpenLinkId(chat: ChatData): Long | null {
196
+ return bsonToLong(chat.li) ?? null
197
+ }
198
+
76
199
  function matchesSearch(chat: ChatData, term: string): boolean {
77
200
  const names = (chat.k ?? []) as string[]
78
201
  const lower = term.toLowerCase()
@@ -214,13 +337,66 @@ function mergeSyncState(previous: SyncState | undefined, loginResult: LoginListR
214
337
  return next
215
338
  }
216
339
 
217
- function formatMessages(logs: Array<Record<string, unknown>>, count: number): KakaoMessage[] {
340
+ function nullableString(v: unknown): string | null {
341
+ return typeof v === 'string' && v.length > 0 ? v : null
342
+ }
343
+
344
+ function nullableNumber(v: unknown): number | null {
345
+ return typeof v === 'number' && Number.isFinite(v) ? v : null
346
+ }
347
+
348
+ function isNonZeroLong(v: unknown): boolean {
349
+ if (typeof v === 'number') return v !== 0
350
+ if (v && typeof v === 'object' && 'low' in v && 'high' in v) {
351
+ const { low, high } = v as { low: number; high: number }
352
+ return low !== 0 || high !== 0
353
+ }
354
+ return v !== undefined && v !== null
355
+ }
356
+
357
+ // Reject synthetic LocoConnection close packets ({ statusCode: -1, body.error: 'connection closed' })
358
+ // and explicit body-level failures. Required for any SDK method whose response body has no
359
+ // caller-visible error channel (e.g. GETMEM/MEMBER return `[]` for both empty rooms and dead
360
+ // sockets). Throwing here lets executeWithReconnect detect session death and reconnect.
361
+ function assertLocoOk(response: LocoPacket, command: string): void {
362
+ if (response.statusCode !== 0) {
363
+ throw new Error(`${command} failed: statusCode=${response.statusCode}`)
364
+ }
365
+ const bodyStatus = response.body.status
366
+ if (typeof bodyStatus === 'number' && bodyStatus !== 0) {
367
+ throw new Error(`${command} failed: body.status=${bodyStatus}`)
368
+ }
369
+ }
370
+
371
+ function formatMember(member: Record<string, unknown>): KakaoMember {
372
+ return {
373
+ user_id: longToString(member.userId),
374
+ nickname: typeof member.nickName === 'string' ? member.nickName : '',
375
+ profile_image_url: nullableString(member.profileImageUrl ?? member.pi),
376
+ full_profile_image_url: nullableString(member.fullProfileImageUrl ?? member.fpi),
377
+ original_profile_image_url: nullableString(member.originalProfileImageUrl ?? member.opi),
378
+ status_message: nullableString(member.statusMessage),
379
+ country_iso: nullableString(member.countryIso),
380
+ user_type: nullableNumber(member.type),
381
+ open_token: nullableNumber(member.opt),
382
+ open_profile_link_id: isNonZeroLong(member.pli) ? longToString(member.pli) : null,
383
+ open_permission: nullableNumber(member.mt),
384
+ }
385
+ }
386
+
387
+ function formatMessages(
388
+ logs: Array<Record<string, unknown>>,
389
+ count: number,
390
+ chatId: string,
391
+ nameCache: MemberNameCache,
392
+ ): KakaoMessage[] {
218
393
  logs.sort((a, b) => (a.sendAt as number) - (b.sendAt as number))
219
394
 
220
395
  return logs.slice(-count).map((log) => ({
221
396
  log_id: longToString(log.logId),
222
397
  type: log.type as number,
223
398
  author_id: log.authorId as number,
399
+ author_name: nameCache.lookup(chatId, log.authorId as number),
224
400
  message: log.message as string,
225
401
  sent_at: log.sendAt as number,
226
402
  }))
@@ -234,6 +410,9 @@ export class KakaoTalkClient {
234
410
  private state: SessionState | null = null
235
411
  private initPromise: Promise<SessionState> | null = null
236
412
  private closed = false
413
+ private pushHandlers = new Set<KakaoPushHandler>()
414
+ private sessionEventHandlers = new Set<KakaoSessionEventHandler>()
415
+ private nameCache = new MemberNameCache()
237
416
 
238
417
  async login(
239
418
  credentials?: { oauthToken: string; userId: string; deviceUuid?: string; deviceType?: KakaoDeviceType },
@@ -280,6 +459,7 @@ export class KakaoTalkClient {
280
459
  if (this.state) return this.state
281
460
 
282
461
  // Guard against concurrent init — reuse the in-flight promise
462
+ const isOwner = !this.initPromise
283
463
  if (!this.initPromise) {
284
464
  this.initPromise = this.connect()
285
465
  }
@@ -291,7 +471,11 @@ export class KakaoTalkClient {
291
471
  state.session.close()
292
472
  throw new KakaoTalkError('Client is closed', 'client_closed')
293
473
  }
474
+ const wasNew = this.state !== state
294
475
  this.state = state
476
+ if (isOwner && wasNew) {
477
+ this.emitSessionEvent({ type: 'connected', userId: this.userId! })
478
+ }
295
479
  return state
296
480
  } catch (error) {
297
481
  // Reset so next call retries cleanly; connect() already wraps in KakaoTalkError
@@ -301,6 +485,29 @@ export class KakaoTalkClient {
301
485
  }
302
486
  }
303
487
 
488
+ async acquireSession(): Promise<LocoSession> {
489
+ const state = await this.ensureSession()
490
+ return state.session
491
+ }
492
+
493
+ onPush(handler: KakaoPushHandler): () => void {
494
+ this.pushHandlers.add(handler)
495
+ return () => {
496
+ this.pushHandlers.delete(handler)
497
+ }
498
+ }
499
+
500
+ onSessionEvent(handler: KakaoSessionEventHandler): () => void {
501
+ this.sessionEventHandlers.add(handler)
502
+ return () => {
503
+ this.sessionEventHandlers.delete(handler)
504
+ }
505
+ }
506
+
507
+ isConnected(): boolean {
508
+ return this.state !== null && !this.closed
509
+ }
510
+
304
511
  private async executeWithReconnect<T>(operation: (state: SessionState) => Promise<T>): Promise<T> {
305
512
  let state = await this.ensureSession()
306
513
  try {
@@ -314,7 +521,10 @@ export class KakaoTalkClient {
314
521
  try {
315
522
  state.session.close()
316
523
  } catch {}
317
- this.initPromise = null
524
+ // initPromise is intentionally NOT cleared here: a concurrent caller may already
525
+ // be awaiting an in-flight replacement, and starting a parallel one would send a
526
+ // second LOGINLIST with the same duuid — re-introducing the very self-eviction
527
+ // this layer prevents. Lifecycle paths (onClose / invalidateSession) own that field.
318
528
  state = await this.ensureSession()
319
529
  return operation(state)
320
530
  }
@@ -322,6 +532,15 @@ export class KakaoTalkClient {
322
532
 
323
533
  private async connect(): Promise<SessionState> {
324
534
  const session = new LocoSession()
535
+ session.onPush((packet) => this.dispatchPush(session, packet))
536
+ session.onClose(() => {
537
+ if (this.state?.session === session) {
538
+ this.state = null
539
+ this.initPromise = null
540
+ this.emitSessionEvent({ type: 'disconnected' })
541
+ }
542
+ })
543
+
325
544
  try {
326
545
  const syncState = await loadSyncState(this.deviceUuid!)
327
546
  const loginResult = await session.login(
@@ -335,12 +554,7 @@ export class KakaoTalkClient {
335
554
  const newSyncState = mergeSyncState(syncState, loginResult)
336
555
  await saveSyncState(this.deviceUuid!, newSyncState)
337
556
 
338
- session.onClose(() => {
339
- if (this.state?.session === session) {
340
- this.state = null
341
- this.initPromise = null
342
- }
343
- })
557
+ this.nameCache.ingest((loginResult.chatDatas ?? []) as ChatData[])
344
558
 
345
559
  return { session, loginResult }
346
560
  } catch (error) {
@@ -349,7 +563,61 @@ export class KakaoTalkClient {
349
563
  }
350
564
  }
351
565
 
352
- async getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]> {
566
+ private dispatchPush(session: LocoSession, packet: LocoPacket): void {
567
+ // Only fan out pushes from the currently adopted session. While state is null
568
+ // (pre-adoption during connect, or post-invalidation during reconnect) the
569
+ // packet is discarded — we never want a not-yet-adopted or already-dead session
570
+ // to reach subscribers and look "live".
571
+ if (this.state?.session !== session) return
572
+
573
+ if (packet.method === 'KICKOUT') {
574
+ this.emitSessionEvent({ type: 'kicked', reason: 'Session kicked — another device logged in' })
575
+ this.invalidateSession(session)
576
+ return
577
+ }
578
+
579
+ if (packet.method === 'CHANGESVR') {
580
+ for (const handler of this.pushHandlers) {
581
+ try {
582
+ handler(packet)
583
+ } catch {}
584
+ }
585
+ this.invalidateSession(session)
586
+ this.emitSessionEvent({ type: 'disconnected' })
587
+ this.ensureSession().catch(() => {
588
+ // ensureSession already cleared state on failure; subsequent API calls will retry
589
+ // and surface the error. Listeners do not receive 'connected' until a reconnect
590
+ // succeeds, which is the correct outcome.
591
+ })
592
+ return
593
+ }
594
+
595
+ for (const handler of this.pushHandlers) {
596
+ try {
597
+ handler(packet)
598
+ } catch {}
599
+ }
600
+ }
601
+
602
+ private invalidateSession(session: LocoSession): void {
603
+ if (this.state?.session === session) {
604
+ this.state = null
605
+ this.initPromise = null
606
+ }
607
+ try {
608
+ session.close()
609
+ } catch {}
610
+ }
611
+
612
+ private emitSessionEvent(event: KakaoSessionEvent): void {
613
+ for (const handler of this.sessionEventHandlers) {
614
+ try {
615
+ handler(event)
616
+ } catch {}
617
+ }
618
+ }
619
+
620
+ async getChats(options?: { all?: boolean; search?: string; resolveTitles?: boolean }): Promise<KakaoChat[]> {
353
621
  return this.executeWithReconnect(async ({ session, loginResult }) => {
354
622
  try {
355
623
  const allChats: ChatData[] = []
@@ -383,6 +651,7 @@ export class KakaoTalkClient {
383
651
  if (chatDatas.length === 0) break
384
652
 
385
653
  collectChats(chatDatas, allChats, seenChatIds)
654
+ this.nameCache.ingest(chatDatas)
386
655
  cursor = body
387
656
  pages++
388
657
  }
@@ -395,13 +664,58 @@ export class KakaoTalkClient {
395
664
  results = allChats.filter((c) => matchesSearch(c, options.search!))
396
665
  }
397
666
 
398
- return results.map(formatChat)
667
+ const titles = options?.resolveTitles
668
+ ? await Promise.all(results.map((chat) => this.fetchChatTitle(session, parseLong(String(chat.c)), chat)))
669
+ : null
670
+
671
+ return results.map((chat, i) => formatChat(chat, titles ? titles[i] : null, this.nameCache))
399
672
  } catch (error) {
400
673
  throw wrapError(error, 'get_chats_failed')
401
674
  }
402
675
  })
403
676
  }
404
677
 
678
+ /**
679
+ * Resolve the user-set room title via CHATINFO. Returns null on any error
680
+ * (network, malformed response, or no TITLE meta present). Designed to be
681
+ * fire-and-forget per chat — failures don't poison the whole `getChats` call.
682
+ */
683
+ async getChatTitle(chatId: string): Promise<string | null> {
684
+ let parsed: Long
685
+ try {
686
+ parsed = parseLong(chatId)
687
+ } catch {
688
+ return null
689
+ }
690
+ return this.executeWithReconnect(async ({ session, loginResult }) => {
691
+ const chat = (loginResult.chatDatas ?? []).find((c) => String(c.c) === chatId)
692
+ return this.fetchChatTitle(session, parsed, chat)
693
+ })
694
+ }
695
+
696
+ private async fetchChatTitle(session: LocoSession, chatId: Long, chat: ChatData | undefined): Promise<string | null> {
697
+ let title: string | null = null
698
+ try {
699
+ const response = await session.getChannelInfo(chatId)
700
+ title = extractTitle(response.body as Record<string, unknown>)
701
+ } catch {
702
+ title = null
703
+ }
704
+
705
+ if (title) return title
706
+ if (!chat || !isOpenChat(chat)) return null
707
+
708
+ const linkId = getOpenLinkId(chat)
709
+ if (!linkId) return null
710
+
711
+ try {
712
+ const response = await session.getOpenLinkInfo([linkId])
713
+ return extractOpenLinkName(response.body as Record<string, unknown>)
714
+ } catch {
715
+ return null
716
+ }
717
+ }
718
+
405
719
  async getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]> {
406
720
  return this.executeWithReconnect(async ({ session }) => {
407
721
  try {
@@ -426,7 +740,7 @@ export class KakaoTalkClient {
426
740
  (log) => longToString(log.chatId) === chatId,
427
741
  )
428
742
  if (batch.length === 0) {
429
- return formatMessages(allMessages, count)
743
+ return formatMessages(allMessages, count, chatId, this.nameCache)
430
744
  }
431
745
 
432
746
  for (const log of batch) {
@@ -439,7 +753,7 @@ export class KakaoTalkClient {
439
753
 
440
754
  const maxLog = findMaxLogId(batch, 'logId')
441
755
  if (!maxLog || maxLog.equals(cur) || response.body.eof) {
442
- return formatMessages(allMessages, count)
756
+ return formatMessages(allMessages, count, chatId, this.nameCache)
443
757
  }
444
758
 
445
759
  cur = maxLog
@@ -452,7 +766,7 @@ export class KakaoTalkClient {
452
766
 
453
767
  if (allMessages.length > 0) {
454
768
  warn(`[agent-kakaotalk] Warning: message fetch capped at ${MAX_PAGES} pages. Results may be incomplete.`)
455
- return formatMessages(allMessages, count)
769
+ return formatMessages(allMessages, count, chatId, this.nameCache)
456
770
  }
457
771
 
458
772
  // Fetch fresh lastLogId via CHATONROOM (not the stale login-time snapshot)
@@ -489,13 +803,43 @@ export class KakaoTalkClient {
489
803
  warn(`[agent-kakaotalk] Warning: message fetch capped at ${MAX_PAGES} pages. Results may be incomplete.`)
490
804
  }
491
805
 
492
- return formatMessages(allMessages, count)
806
+ return formatMessages(allMessages, count, chatId, this.nameCache)
493
807
  } catch (error) {
494
808
  throw wrapError(error, 'get_messages_failed')
495
809
  }
496
810
  })
497
811
  }
498
812
 
813
+ async getMembers(chatId: string): Promise<KakaoMember[]> {
814
+ const parsedChatId = parseChatId(chatId)
815
+ return this.executeWithReconnect(async ({ session }) => {
816
+ try {
817
+ const response = await session.getAllMembers(parsedChatId)
818
+ assertLocoOk(response, 'GETMEM')
819
+ const members = (response.body.members ?? []) as Array<Record<string, unknown>>
820
+ return members.map(formatMember)
821
+ } catch (error) {
822
+ throw wrapError(error, 'get_members_failed')
823
+ }
824
+ })
825
+ }
826
+
827
+ async getMembersByIds(chatId: string, userIds: string[]): Promise<KakaoMember[]> {
828
+ if (userIds.length === 0) return []
829
+ const parsedChatId = parseChatId(chatId)
830
+ const memberIds = userIds.map((id) => parseUserId(id))
831
+ return this.executeWithReconnect(async ({ session }) => {
832
+ try {
833
+ const response = await session.getMembersByIds(parsedChatId, memberIds)
834
+ assertLocoOk(response, 'MEMBER')
835
+ const members = (response.body.members ?? []) as Array<Record<string, unknown>>
836
+ return members.map(formatMember)
837
+ } catch (error) {
838
+ throw wrapError(error, 'get_members_failed')
839
+ }
840
+ })
841
+ }
842
+
499
843
  async sendMessage(chatId: string, text: string): Promise<KakaoSendResult> {
500
844
  return this.executeWithReconnect(async ({ session }) => {
501
845
  try {
@@ -584,5 +928,12 @@ export class KakaoTalkClient {
584
928
  }
585
929
  this.state = null
586
930
  this.initPromise = null
931
+ this.pushHandlers.clear()
932
+ this.sessionEventHandlers.clear()
933
+ this.nameCache.clear()
934
+ }
935
+
936
+ lookupAuthorName(chatId: string, authorId: number): string | null {
937
+ return this.nameCache.lookup(chatId, authorId)
587
938
  }
588
939
  }
@@ -9,11 +9,12 @@ async function listAction(options: {
9
9
  account?: string
10
10
  all?: boolean
11
11
  search?: string
12
+ resolveTitles?: boolean
12
13
  pretty?: boolean
13
14
  }): Promise<void> {
14
15
  try {
15
16
  const chats = await withKakaoClient(options, (client) =>
16
- client.getChats({ all: options.all, search: options.search }),
17
+ client.getChats({ all: options.all, search: options.search, resolveTitles: options.resolveTitles }),
17
18
  )
18
19
  console.log(formatOutput(chats, options.pretty))
19
20
  } catch (error) {
@@ -29,6 +30,7 @@ export const chatCommand = new Command('chat')
29
30
  .option('--account <id>', 'Use a specific KakaoTalk account')
30
31
  .option('--all', 'Fetch all chats (paginate beyond login snapshot)')
31
32
  .option('--search <name>', 'Search for a chat by display name')
33
+ .option('--resolve-titles', 'Fetch user-set room titles via CHATINFO (slower; one extra LOCO call per chat)')
32
34
  .option('--pretty', 'Pretty print JSON output')
33
35
  .action(listAction),
34
36
  )
@@ -1,4 +1,5 @@
1
1
  export { authCommand } from './auth'
2
2
  export { chatCommand } from './chat'
3
+ export { memberCommand } from './member'
3
4
  export { messageCommand } from './message'
4
5
  export { whoamiCommand } from './whoami'
@@ -0,0 +1,102 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, it } from 'bun:test'
2
+
3
+ const originalConsoleLog = console.log
4
+
5
+ const mockWithKakaoClient = mock(async (_options: unknown, fn: (client: unknown) => Promise<unknown>) => {
6
+ return fn(mockClient)
7
+ })
8
+
9
+ const mockGetMembers = mock(() =>
10
+ Promise.resolve([
11
+ {
12
+ user_id: '42',
13
+ nickname: 'Alice',
14
+ profile_image_url: null,
15
+ full_profile_image_url: null,
16
+ original_profile_image_url: null,
17
+ status_message: null,
18
+ country_iso: null,
19
+ user_type: 100,
20
+ open_token: null,
21
+ open_profile_link_id: null,
22
+ open_permission: null,
23
+ },
24
+ ]),
25
+ )
26
+
27
+ const mockClient = {
28
+ getMembers: mockGetMembers,
29
+ }
30
+
31
+ mock.module('./shared', () => ({
32
+ withKakaoClient: mockWithKakaoClient,
33
+ }))
34
+
35
+ import { memberCommand } from './member'
36
+
37
+ describe('member commands', () => {
38
+ let consoleLogSpy: ReturnType<typeof mock>
39
+
40
+ beforeEach(() => {
41
+ mockWithKakaoClient.mockReset()
42
+ mockGetMembers.mockReset()
43
+
44
+ mockWithKakaoClient.mockImplementation(async (_options: unknown, fn: (client: unknown) => Promise<unknown>) => {
45
+ return fn(mockClient)
46
+ })
47
+ mockGetMembers.mockImplementation(() =>
48
+ Promise.resolve([
49
+ {
50
+ user_id: '42',
51
+ nickname: 'Alice',
52
+ profile_image_url: null,
53
+ full_profile_image_url: null,
54
+ original_profile_image_url: null,
55
+ status_message: null,
56
+ country_iso: null,
57
+ user_type: 100,
58
+ open_token: null,
59
+ open_profile_link_id: null,
60
+ open_permission: null,
61
+ },
62
+ ]),
63
+ )
64
+
65
+ consoleLogSpy = mock((..._args: unknown[]) => {})
66
+ console.log = consoleLogSpy
67
+ })
68
+
69
+ afterEach(() => {
70
+ console.log = originalConsoleLog
71
+ })
72
+
73
+ describe('list', () => {
74
+ it('lists members of a chat room', async () => {
75
+ await memberCommand.parseAsync(['list', '9876543210'], { from: 'user' })
76
+
77
+ expect(mockGetMembers).toHaveBeenCalledWith('9876543210')
78
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0])
79
+ expect(output).toHaveLength(1)
80
+ expect(output[0].user_id).toBe('42')
81
+ expect(output[0].nickname).toBe('Alice')
82
+ })
83
+
84
+ it('passes --account option to withKakaoClient', async () => {
85
+ await memberCommand.parseAsync(['list', '9876543210', '--account', 'my-account'], { from: 'user' })
86
+
87
+ expect(mockWithKakaoClient).toHaveBeenCalledWith(
88
+ expect.objectContaining({ account: 'my-account' }),
89
+ expect.any(Function),
90
+ )
91
+ })
92
+
93
+ it('outputs empty array when chat has no members', async () => {
94
+ mockGetMembers.mockImplementation(() => Promise.resolve([]))
95
+
96
+ await memberCommand.parseAsync(['list', '9876543210'], { from: 'user' })
97
+
98
+ const output = JSON.parse(consoleLogSpy.mock.calls[0][0])
99
+ expect(output).toEqual([])
100
+ })
101
+ })
102
+ })