agent-messenger 2.12.2 → 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 (85) 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 +13 -1
  11. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  12. package/dist/src/platforms/kakaotalk/client.js +225 -8
  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.map +1 -1
  30. package/dist/src/platforms/kakaotalk/listener.js +5 -2
  31. package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
  32. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +28 -0
  33. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  34. package/dist/src/platforms/kakaotalk/protocol/session.js +44 -0
  35. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  36. package/dist/src/platforms/kakaotalk/types.d.ts +37 -0
  37. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  38. package/dist/src/platforms/kakaotalk/types.js +17 -0
  39. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  40. package/dist/src/platforms/slackbot/client.d.ts +5 -0
  41. package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
  42. package/dist/src/platforms/slackbot/client.js +5 -0
  43. package/dist/src/platforms/slackbot/client.js.map +1 -1
  44. package/dist/src/tui/adapters/kakaotalk-adapter.js +3 -3
  45. package/dist/src/tui/adapters/kakaotalk-adapter.js.map +1 -1
  46. package/docs/content/docs/cli/kakaotalk.mdx +26 -1
  47. package/docs/content/docs/sdk/kakaotalk.mdx +45 -13
  48. package/docs/content/docs/sdk/slackbot.mdx +11 -0
  49. package/package.json +1 -1
  50. package/scripts/kakao-loco-capture.ts +466 -0
  51. package/skills/agent-channeltalk/SKILL.md +1 -1
  52. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  53. package/skills/agent-discord/SKILL.md +1 -1
  54. package/skills/agent-discordbot/SKILL.md +1 -1
  55. package/skills/agent-instagram/SKILL.md +1 -1
  56. package/skills/agent-kakaotalk/SKILL.md +30 -3
  57. package/skills/agent-kakaotalk/references/common-patterns.md +49 -1
  58. package/skills/agent-line/SKILL.md +1 -1
  59. package/skills/agent-slack/SKILL.md +1 -1
  60. package/skills/agent-slackbot/SKILL.md +1 -2
  61. package/skills/agent-teams/SKILL.md +1 -1
  62. package/skills/agent-telegram/SKILL.md +1 -1
  63. package/skills/agent-telegrambot/SKILL.md +1 -1
  64. package/skills/agent-webex/SKILL.md +1 -1
  65. package/skills/agent-wechatbot/SKILL.md +1 -1
  66. package/skills/agent-whatsapp/SKILL.md +1 -1
  67. package/skills/agent-whatsappbot/SKILL.md +1 -1
  68. package/src/platforms/kakaotalk/chat-classifier.test.ts +33 -0
  69. package/src/platforms/kakaotalk/chat-classifier.ts +31 -0
  70. package/src/platforms/kakaotalk/cli.ts +2 -1
  71. package/src/platforms/kakaotalk/client.test.ts +782 -1
  72. package/src/platforms/kakaotalk/client.ts +262 -10
  73. package/src/platforms/kakaotalk/commands/chat.ts +3 -1
  74. package/src/platforms/kakaotalk/commands/index.ts +1 -0
  75. package/src/platforms/kakaotalk/commands/member.test.ts +102 -0
  76. package/src/platforms/kakaotalk/commands/member.ts +32 -0
  77. package/src/platforms/kakaotalk/index.test.ts +5 -0
  78. package/src/platforms/kakaotalk/index.ts +4 -0
  79. package/src/platforms/kakaotalk/listener.test.ts +29 -0
  80. package/src/platforms/kakaotalk/listener.ts +5 -2
  81. package/src/platforms/kakaotalk/protocol/session.ts +44 -0
  82. package/src/platforms/kakaotalk/types.ts +39 -0
  83. package/src/platforms/slackbot/client.test.ts +67 -0
  84. package/src/platforms/slackbot/client.ts +17 -1
  85. package/src/tui/adapters/kakaotalk-adapter.ts +3 -3
@@ -10,7 +10,7 @@ import { warn } from '@/shared/utils/stderr'
10
10
  import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
11
11
  import { LocoSession } from './protocol/session'
12
12
  import type { ChatListResponse, LocoPacket, LoginListResponse, SyncState } from './protocol/types'
13
- import type { KakaoChat, KakaoDeviceType, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
13
+ import type { KakaoChat, KakaoDeviceType, KakaoMember, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
14
14
 
15
15
  export type KakaoSessionEvent =
16
16
  | { type: 'connected'; userId: string }
@@ -37,6 +37,58 @@ interface SessionState {
37
37
  loginResult: LoginListResponse
38
38
  }
39
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
+
40
92
  function bsonToLong(v: unknown): Long | undefined {
41
93
  if (v && typeof v === 'object' && 'high' in v && 'low' in v) {
42
94
  const { high, low } = v as { high: number; low: number }
@@ -60,20 +112,39 @@ function parseLong(s: string): Long {
60
112
  return new Long(low, high)
61
113
  }
62
114
 
63
- 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 {
64
132
  const memberNames = (chat.k ?? []) as string[]
65
133
  const lastLog = chat.l as Record<string, unknown> | null
66
134
  const displayName = memberNames.join(', ') || null
135
+ const chatId = String(chat.c)
67
136
 
68
137
  return {
69
- chat_id: String(chat.c),
138
+ chat_id: chatId,
70
139
  type: chat.t as number,
71
140
  display_name: displayName,
141
+ title,
72
142
  active_members: chat.a as number,
73
143
  unread_count: chat.n as number,
74
144
  last_message: lastLog
75
145
  ? {
76
146
  author_id: lastLog.authorId as number,
147
+ author_name: nameCache.lookup(chatId, lastLog.authorId as number),
77
148
  message: lastLog.message as string,
78
149
  sent_at: lastLog.sendAt as number,
79
150
  }
@@ -81,6 +152,50 @@ function formatChat(chat: ChatData): KakaoChat {
81
152
  }
82
153
  }
83
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
+
84
199
  function matchesSearch(chat: ChatData, term: string): boolean {
85
200
  const names = (chat.k ?? []) as string[]
86
201
  const lower = term.toLowerCase()
@@ -222,13 +337,66 @@ function mergeSyncState(previous: SyncState | undefined, loginResult: LoginListR
222
337
  return next
223
338
  }
224
339
 
225
- 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[] {
226
393
  logs.sort((a, b) => (a.sendAt as number) - (b.sendAt as number))
227
394
 
228
395
  return logs.slice(-count).map((log) => ({
229
396
  log_id: longToString(log.logId),
230
397
  type: log.type as number,
231
398
  author_id: log.authorId as number,
399
+ author_name: nameCache.lookup(chatId, log.authorId as number),
232
400
  message: log.message as string,
233
401
  sent_at: log.sendAt as number,
234
402
  }))
@@ -244,6 +412,7 @@ export class KakaoTalkClient {
244
412
  private closed = false
245
413
  private pushHandlers = new Set<KakaoPushHandler>()
246
414
  private sessionEventHandlers = new Set<KakaoSessionEventHandler>()
415
+ private nameCache = new MemberNameCache()
247
416
 
248
417
  async login(
249
418
  credentials?: { oauthToken: string; userId: string; deviceUuid?: string; deviceType?: KakaoDeviceType },
@@ -385,6 +554,8 @@ export class KakaoTalkClient {
385
554
  const newSyncState = mergeSyncState(syncState, loginResult)
386
555
  await saveSyncState(this.deviceUuid!, newSyncState)
387
556
 
557
+ this.nameCache.ingest((loginResult.chatDatas ?? []) as ChatData[])
558
+
388
559
  return { session, loginResult }
389
560
  } catch (error) {
390
561
  session.close()
@@ -446,7 +617,7 @@ export class KakaoTalkClient {
446
617
  }
447
618
  }
448
619
 
449
- async getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]> {
620
+ async getChats(options?: { all?: boolean; search?: string; resolveTitles?: boolean }): Promise<KakaoChat[]> {
450
621
  return this.executeWithReconnect(async ({ session, loginResult }) => {
451
622
  try {
452
623
  const allChats: ChatData[] = []
@@ -480,6 +651,7 @@ export class KakaoTalkClient {
480
651
  if (chatDatas.length === 0) break
481
652
 
482
653
  collectChats(chatDatas, allChats, seenChatIds)
654
+ this.nameCache.ingest(chatDatas)
483
655
  cursor = body
484
656
  pages++
485
657
  }
@@ -492,13 +664,58 @@ export class KakaoTalkClient {
492
664
  results = allChats.filter((c) => matchesSearch(c, options.search!))
493
665
  }
494
666
 
495
- 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))
496
672
  } catch (error) {
497
673
  throw wrapError(error, 'get_chats_failed')
498
674
  }
499
675
  })
500
676
  }
501
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
+
502
719
  async getMessages(chatId: string, options?: { count?: number; from?: string }): Promise<KakaoMessage[]> {
503
720
  return this.executeWithReconnect(async ({ session }) => {
504
721
  try {
@@ -523,7 +740,7 @@ export class KakaoTalkClient {
523
740
  (log) => longToString(log.chatId) === chatId,
524
741
  )
525
742
  if (batch.length === 0) {
526
- return formatMessages(allMessages, count)
743
+ return formatMessages(allMessages, count, chatId, this.nameCache)
527
744
  }
528
745
 
529
746
  for (const log of batch) {
@@ -536,7 +753,7 @@ export class KakaoTalkClient {
536
753
 
537
754
  const maxLog = findMaxLogId(batch, 'logId')
538
755
  if (!maxLog || maxLog.equals(cur) || response.body.eof) {
539
- return formatMessages(allMessages, count)
756
+ return formatMessages(allMessages, count, chatId, this.nameCache)
540
757
  }
541
758
 
542
759
  cur = maxLog
@@ -549,7 +766,7 @@ export class KakaoTalkClient {
549
766
 
550
767
  if (allMessages.length > 0) {
551
768
  warn(`[agent-kakaotalk] Warning: message fetch capped at ${MAX_PAGES} pages. Results may be incomplete.`)
552
- return formatMessages(allMessages, count)
769
+ return formatMessages(allMessages, count, chatId, this.nameCache)
553
770
  }
554
771
 
555
772
  // Fetch fresh lastLogId via CHATONROOM (not the stale login-time snapshot)
@@ -586,13 +803,43 @@ export class KakaoTalkClient {
586
803
  warn(`[agent-kakaotalk] Warning: message fetch capped at ${MAX_PAGES} pages. Results may be incomplete.`)
587
804
  }
588
805
 
589
- return formatMessages(allMessages, count)
806
+ return formatMessages(allMessages, count, chatId, this.nameCache)
590
807
  } catch (error) {
591
808
  throw wrapError(error, 'get_messages_failed')
592
809
  }
593
810
  })
594
811
  }
595
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
+
596
843
  async sendMessage(chatId: string, text: string): Promise<KakaoSendResult> {
597
844
  return this.executeWithReconnect(async ({ session }) => {
598
845
  try {
@@ -683,5 +930,10 @@ export class KakaoTalkClient {
683
930
  this.initPromise = null
684
931
  this.pushHandlers.clear()
685
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)
686
938
  }
687
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
+ })
@@ -0,0 +1,32 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { handleError } from '@/shared/utils/error-handler'
4
+ import { formatOutput } from '@/shared/utils/output'
5
+
6
+ import { withKakaoClient } from './shared'
7
+
8
+ async function listAction(
9
+ chatId: string,
10
+ options: {
11
+ account?: string
12
+ pretty?: boolean
13
+ },
14
+ ): Promise<void> {
15
+ try {
16
+ const members = await withKakaoClient(options, (client) => client.getMembers(chatId))
17
+ console.log(formatOutput(members, options.pretty))
18
+ } catch (error) {
19
+ handleError(error as Error)
20
+ }
21
+ }
22
+
23
+ export const memberCommand = new Command('member')
24
+ .description('KakaoTalk member commands')
25
+ .addCommand(
26
+ new Command('list')
27
+ .description('List members of a chat room')
28
+ .argument('<chat-id>', 'Chat room ID')
29
+ .option('--account <id>', 'Use a specific KakaoTalk account')
30
+ .option('--pretty', 'Pretty print JSON output')
31
+ .action(listAction),
32
+ )
@@ -1,6 +1,7 @@
1
1
  import { expect, it } from 'bun:test'
2
2
 
3
3
  import {
4
+ classifyKakaoChat,
4
5
  CredentialManager,
5
6
  KakaoAccountCredentialsSchema,
6
7
  KakaoCredentialManager,
@@ -72,3 +73,7 @@ it('KakaoTalkPushReadEventSchema is exported from barrel', () => {
72
73
  it('KakaoProfileSchema is exported from barrel', () => {
73
74
  expect(typeof KakaoProfileSchema.parse).toBe('function')
74
75
  })
76
+
77
+ it('classifyKakaoChat is exported from barrel', () => {
78
+ expect(typeof classifyKakaoChat).toBe('function')
79
+ })
@@ -1,4 +1,6 @@
1
1
  export { KakaoTalkClient, KakaoTalkError } from './client'
2
+ export { classifyKakaoChat } from './chat-classifier'
3
+ export type { KakaoChatKind } from './chat-classifier'
2
4
  export { KakaoCredentialManager, CredentialManager } from './credential-manager'
3
5
  export { KakaoTalkListener } from './listener'
4
6
  export type { PendingLoginState } from './credential-manager'
@@ -8,6 +10,7 @@ export type {
8
10
  KakaoChat,
9
11
  KakaoConfig,
10
12
  KakaoDeviceType,
13
+ KakaoMember,
11
14
  KakaoMessage,
12
15
  KakaoProfile,
13
16
  KakaoSendResult,
@@ -22,6 +25,7 @@ export {
22
25
  KakaoAccountCredentialsSchema,
23
26
  KakaoChatSchema,
24
27
  KakaoConfigSchema,
28
+ KakaoMemberSchema,
25
29
  KakaoMessageSchema,
26
30
  KakaoProfileSchema,
27
31
  KakaoSendResultSchema,
@@ -16,6 +16,7 @@ class FakeClient {
16
16
  sessionHandlers = new Set<KakaoSessionEventHandler>()
17
17
  acquireImpl: () => Promise<void> = async () => {}
18
18
  connected = false
19
+ lookupAuthorName: (chatId: string, authorId: number) => string | null = () => null
19
20
 
20
21
  async acquireSession(): Promise<unknown> {
21
22
  this.acquireCalls++
@@ -141,10 +142,38 @@ describe('KakaoTalkListener', () => {
141
142
  expect(messages[0].chat_id).toBe('100')
142
143
  expect(messages[0].log_id).toBe('200')
143
144
  expect(messages[0].author_id).toBe(42)
145
+ expect(messages[0].author_name).toBeNull()
144
146
  expect(messages[0].message).toBe('hello world')
145
147
  expect(messages[0].message_type).toBe(1)
146
148
  expect(messages[0].sent_at).toBe(1700000000)
147
149
  })
150
+
151
+ it('resolves author_name from client.lookupAuthorName when available', async () => {
152
+ const { listener: l, client } = createListener()
153
+ listener = l
154
+
155
+ client.lookupAuthorName = (chatId: string, authorId: number) => {
156
+ if (chatId === '100' && authorId === 42) return 'Alice'
157
+ return null
158
+ }
159
+
160
+ const messages: KakaoTalkPushMessageEvent[] = []
161
+ listener.on('message', (event) => messages.push(event))
162
+
163
+ await listener.start()
164
+ client.emitPush('MSG', {
165
+ chatId: { high: 0, low: 100 },
166
+ chatLog: {
167
+ logId: { high: 0, low: 200 },
168
+ authorId: 42,
169
+ message: 'hello',
170
+ type: 1,
171
+ sendAt: 1700000000,
172
+ },
173
+ })
174
+
175
+ expect(messages[0].author_name).toBe('Alice')
176
+ })
148
177
  })
149
178
 
150
179
  describe('member events', () => {
@@ -109,11 +109,14 @@ export class KakaoTalkListener {
109
109
  switch (method) {
110
110
  case 'MSG': {
111
111
  const chatLog = body.chatLog as Record<string, unknown>
112
+ const chatId = longToString(body.chatId)
113
+ const authorId = chatLog.authorId as number
112
114
  const event: KakaoTalkPushMessageEvent = {
113
115
  type: 'MSG',
114
- chat_id: longToString(body.chatId),
116
+ chat_id: chatId,
115
117
  log_id: longToString(chatLog.logId),
116
- author_id: chatLog.authorId as number,
118
+ author_id: authorId,
119
+ author_name: this.client.lookupAuthorName?.(chatId, authorId) ?? null,
117
120
  message: chatLog.message as string,
118
121
  message_type: chatLog.type as number,
119
122
  sent_at: chatLog.sendAt as number,