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
@@ -7,10 +7,15 @@ const mockLogin = mock(() => Promise.resolve({}))
7
7
  const mockGetChatList = mock(() => Promise.resolve({}))
8
8
  const mockGetChatLogs = mock(() => Promise.resolve({}))
9
9
  const mockGetChatInfo = mock(() => Promise.resolve({}))
10
+ const mockGetChannelInfo = mock(() => Promise.resolve({}))
11
+ const mockGetOpenLinkInfo = mock(() => Promise.resolve({}))
12
+ const mockGetAllMembers = mock(() => Promise.resolve({}))
13
+ const mockGetMembersByIds = mock(() => Promise.resolve({}))
10
14
  const mockSyncMessages = mock(() => Promise.resolve({}))
11
15
  const mockSendMessage = mock(() => Promise.resolve({}))
12
16
  const mockClose = mock(() => {})
13
17
  const mockOnClose = mock((_handler: () => void) => {})
18
+ const mockOnPush = mock((_handler: (packet: unknown) => void) => {})
14
19
 
15
20
  mock.module('./protocol/session', () => ({
16
21
  LocoSession: class MockLocoSession {
@@ -18,10 +23,15 @@ mock.module('./protocol/session', () => ({
18
23
  getChatList = mockGetChatList
19
24
  getChatLogs = mockGetChatLogs
20
25
  getChatInfo = mockGetChatInfo
26
+ getChannelInfo = mockGetChannelInfo
27
+ getOpenLinkInfo = mockGetOpenLinkInfo
28
+ getAllMembers = mockGetAllMembers
29
+ getMembersByIds = mockGetMembersByIds
21
30
  syncMessages = mockSyncMessages
22
31
  sendMessage = mockSendMessage
23
32
  close = mockClose
24
33
  onClose = mockOnClose
34
+ onPush = mockOnPush
25
35
  },
26
36
  }))
27
37
 
@@ -34,19 +44,26 @@ function resetAllMocks() {
34
44
  mockGetChatList.mockReset()
35
45
  mockGetChatLogs.mockReset()
36
46
  mockGetChatInfo.mockReset()
47
+ mockGetChannelInfo.mockReset()
48
+ mockGetOpenLinkInfo.mockReset()
49
+ mockGetAllMembers.mockReset()
50
+ mockGetMembersByIds.mockReset()
37
51
  mockSyncMessages.mockReset()
38
52
  mockSendMessage.mockReset()
39
53
  mockClose.mockReset()
40
54
  mockOnClose.mockReset()
55
+ mockOnPush.mockReset()
41
56
  }
42
57
 
43
- // LOCO protocol uses plain numbers for chat.c, but Long-like objects for logIds/cursors
58
+ // LOCO protocol uses plain numbers for chat.c, but Long-like objects for logIds/cursors.
59
+ // `i` and `k` are paired arrays — i[n] is the user_id, k[n] is the nickname.
44
60
  const DEFAULT_LOGIN_RESULT = {
45
61
  chatDatas: [
46
62
  {
47
63
  c: 100,
48
64
  t: 1,
49
65
  k: ['Alice', 'Bob'],
66
+ i: [1, 2],
50
67
  a: 2,
51
68
  n: 3,
52
69
  o: 1700000000,
@@ -57,6 +74,7 @@ const DEFAULT_LOGIN_RESULT = {
57
74
  c: 200,
58
75
  t: 2,
59
76
  k: ['Charlie'],
77
+ i: [3],
60
78
  a: 1,
61
79
  n: 0,
62
80
  o: 1699999000,
@@ -120,19 +138,248 @@ describe('KakaoTalkClient', () => {
120
138
 
121
139
  expect(chats).toHaveLength(2)
122
140
  expect(chats[0].display_name).toBe('Alice, Bob')
141
+ expect(chats[0].title).toBeNull()
123
142
  expect(chats[0].active_members).toBe(2)
124
143
  expect(chats[0].unread_count).toBe(3)
125
144
  expect(chats[0].last_message).toEqual({
126
145
  author_id: 1,
146
+ author_name: 'Alice',
127
147
  message: 'hi',
128
148
  sent_at: 1700000000,
129
149
  })
130
150
  expect(chats[1].display_name).toBe('Charlie')
151
+ expect(chats[1].title).toBeNull()
131
152
  expect(chats[1].last_message).toBeNull()
132
153
 
133
154
  client.close()
134
155
  })
135
156
 
157
+ it('populates last_message.author_name from paired chat.i / chat.k arrays', async () => {
158
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
159
+ const chats = await client.getChats()
160
+
161
+ expect(chats[0].last_message?.author_name).toBe('Alice')
162
+ })
163
+
164
+ it('returns null author_name when authorId is not in the paired arrays', async () => {
165
+ const customLogin = {
166
+ ...DEFAULT_LOGIN_RESULT,
167
+ chatDatas: [
168
+ {
169
+ c: 100,
170
+ t: 1,
171
+ k: ['Alice'],
172
+ i: [1],
173
+ a: 1,
174
+ n: 0,
175
+ o: 1700000000,
176
+ l: { authorId: 99, message: 'from a stranger', sendAt: 1700000000 },
177
+ ll: makeLong(999),
178
+ },
179
+ ],
180
+ }
181
+ mockLogin.mockResolvedValue(customLogin)
182
+
183
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
184
+ const chats = await client.getChats()
185
+
186
+ expect(chats[0].last_message?.author_id).toBe(99)
187
+ expect(chats[0].last_message?.author_name).toBeNull()
188
+ })
189
+
190
+ it('returns null author_name when chat.i is missing (no paired data)', async () => {
191
+ const customLogin = {
192
+ ...DEFAULT_LOGIN_RESULT,
193
+ chatDatas: [
194
+ {
195
+ c: 100,
196
+ t: 1,
197
+ k: ['Alice'],
198
+ a: 1,
199
+ n: 0,
200
+ o: 1700000000,
201
+ l: { authorId: 1, message: 'hi', sendAt: 1700000000 },
202
+ ll: makeLong(999),
203
+ },
204
+ ],
205
+ }
206
+ mockLogin.mockResolvedValue(customLogin)
207
+
208
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
209
+ const chats = await client.getChats()
210
+
211
+ expect(chats[0].last_message?.author_name).toBeNull()
212
+ })
213
+
214
+ it('does not call CHATINFO when resolveTitles is not set (default behavior preserved)', async () => {
215
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
216
+ await client.getChats()
217
+
218
+ expect(mockGetChannelInfo).not.toHaveBeenCalled()
219
+
220
+ client.close()
221
+ })
222
+
223
+ it('resolves user-set titles via CHATINFO when resolveTitles=true', async () => {
224
+ mockGetChannelInfo.mockResolvedValueOnce({
225
+ body: {
226
+ chatInfo: {
227
+ chatMetas: [
228
+ { type: 1, content: '{"notice":"hello"}', revision: makeLong(1), updatedAt: 0 },
229
+ { type: 3, content: 'Renamed Chat', revision: makeLong(2), updatedAt: 0 },
230
+ ],
231
+ },
232
+ },
233
+ })
234
+ mockGetChannelInfo.mockResolvedValueOnce({
235
+ body: {
236
+ chatInfo: {
237
+ chatMetas: [],
238
+ },
239
+ },
240
+ })
241
+
242
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
243
+ const chats = await client.getChats({ resolveTitles: true })
244
+
245
+ expect(mockGetChannelInfo).toHaveBeenCalledTimes(2)
246
+ expect(chats[0].title).toBe('Renamed Chat')
247
+ expect(chats[0].display_name).toBe('Alice, Bob')
248
+ expect(chats[1].title).toBeNull()
249
+ expect(chats[1].display_name).toBe('Charlie')
250
+
251
+ client.close()
252
+ })
253
+
254
+ it('returns null title when chatInfo or chatMetas is missing', async () => {
255
+ mockGetChannelInfo.mockResolvedValue({ body: {} })
256
+
257
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
258
+ const chats = await client.getChats({ resolveTitles: true })
259
+
260
+ for (const chat of chats) {
261
+ expect(chat.title).toBeNull()
262
+ }
263
+
264
+ client.close()
265
+ })
266
+
267
+ it('swallows CHATINFO failures and returns null title (does not poison list)', async () => {
268
+ mockGetChannelInfo.mockRejectedValueOnce(new Error('CHATINFO timed out'))
269
+ mockGetChannelInfo.mockResolvedValueOnce({
270
+ body: { chatInfo: { chatMetas: [{ type: 3, content: 'OK Title' }] } },
271
+ })
272
+
273
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
274
+ const chats = await client.getChats({ resolveTitles: true })
275
+
276
+ expect(chats).toHaveLength(2)
277
+ expect(chats[0].title).toBeNull()
278
+ expect(chats[1].title).toBe('OK Title')
279
+
280
+ client.close()
281
+ })
282
+
283
+ it('ignores empty-string title content and returns null', async () => {
284
+ mockGetChannelInfo.mockResolvedValue({
285
+ body: { chatInfo: { chatMetas: [{ type: 3, content: '' }] } },
286
+ })
287
+
288
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
289
+ const chats = await client.getChats({ resolveTitles: true })
290
+
291
+ for (const chat of chats) {
292
+ expect(chat.title).toBeNull()
293
+ }
294
+
295
+ client.close()
296
+ })
297
+
298
+ it('resolves open-chat titles via INFOLINK fallback in batch resolveTitles=true path', async () => {
299
+ // Mixed login snapshot: regular chat (uses CHATINFO TITLE meta), open chat
300
+ // with TITLE meta (CHATINFO only), open chat without TITLE meta (CHATINFO
301
+ // empty + INFOLINK fallback). Verifies the batch path wires open-chat
302
+ // fallback correctly — previously only the single-chat getChatTitle()
303
+ // path was covered.
304
+ const mixedLogin = {
305
+ chatDatas: [
306
+ {
307
+ c: 100,
308
+ t: 1,
309
+ k: ['Alice'],
310
+ i: [1],
311
+ a: 1,
312
+ n: 0,
313
+ o: 1700000003,
314
+ l: null,
315
+ ll: makeLong(1),
316
+ },
317
+ {
318
+ c: 500,
319
+ t: 'OM',
320
+ li: makeLong(7777),
321
+ k: ['User1'],
322
+ i: [10],
323
+ a: 1,
324
+ n: 0,
325
+ o: 1700000002,
326
+ l: null,
327
+ ll: makeLong(2),
328
+ },
329
+ {
330
+ c: 600,
331
+ t: 'OD',
332
+ li: makeLong(8888),
333
+ k: ['User2'],
334
+ i: [20],
335
+ a: 1,
336
+ n: 0,
337
+ o: 1700000001,
338
+ l: null,
339
+ ll: makeLong(3),
340
+ },
341
+ ],
342
+ lastTokenId: makeLong(0),
343
+ lastChatId: makeLong(0),
344
+ eof: true,
345
+ }
346
+ mockLogin.mockResolvedValue(mixedLogin)
347
+
348
+ // CHATINFO is fired in parallel for all 3 chats; mock by chatId so order
349
+ // doesn't matter (Promise.all resolves in fire order, not array order).
350
+ mockGetChannelInfo.mockImplementation((chatId: { low: number; high: number }) => {
351
+ switch (chatId.low) {
352
+ case 100:
353
+ return Promise.resolve({ body: { chatInfo: { chatMetas: [{ type: 3, content: 'Regular Title' }] } } })
354
+ case 500:
355
+ return Promise.resolve({ body: { chatInfo: { chatMetas: [{ type: 3, content: 'Open With Title' }] } } })
356
+ case 600:
357
+ return Promise.resolve({ body: { chatInfo: { chatMetas: [] } } })
358
+ default:
359
+ return Promise.resolve({ body: {} })
360
+ }
361
+ })
362
+ mockGetOpenLinkInfo.mockResolvedValueOnce({ body: { ols: [{ ln: 'Open Link Name' }] } })
363
+
364
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
365
+ const chats = await client.getChats({ resolveTitles: true })
366
+
367
+ const byId = Object.fromEntries(chats.map((c) => [c.chat_id, c]))
368
+ expect(byId['100'].title).toBe('Regular Title')
369
+ expect(byId['500'].title).toBe('Open With Title')
370
+ expect(byId['600'].title).toBe('Open Link Name')
371
+
372
+ // INFOLINK fires only for the open chat that lacked a TITLE meta — not
373
+ // for the regular chat (skipped by isOpenChat) and not for the open
374
+ // chat that already had a TITLE (skipped by short-circuit).
375
+ expect(mockGetOpenLinkInfo).toHaveBeenCalledTimes(1)
376
+ const [linkIds] = mockGetOpenLinkInfo.mock.calls[0] as [Array<{ low: number; high: number }>]
377
+ expect(linkIds).toHaveLength(1)
378
+ expect(linkIds[0]).toMatchObject({ low: 8888, high: 0 })
379
+
380
+ client.close()
381
+ })
382
+
136
383
  it('sorts chats by recency (o field descending)', async () => {
137
384
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
138
385
  const chats = await client.getChats()
@@ -304,6 +551,185 @@ describe('KakaoTalkClient', () => {
304
551
  client.close()
305
552
  })
306
553
 
554
+ it('exposes getChatTitle() for single-chat title resolution', async () => {
555
+ mockGetChannelInfo.mockResolvedValueOnce({
556
+ body: { chatInfo: { chatMetas: [{ type: 3, content: 'Direct Lookup' }] } },
557
+ })
558
+
559
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
560
+ const title = await client.getChatTitle('100')
561
+
562
+ expect(title).toBe('Direct Lookup')
563
+ expect(mockGetChannelInfo).toHaveBeenCalledTimes(1)
564
+
565
+ client.close()
566
+ })
567
+
568
+ it('getChatTitle() returns null on CHATINFO failure', async () => {
569
+ mockGetChannelInfo.mockRejectedValueOnce(new Error('Network error'))
570
+
571
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
572
+ const title = await client.getChatTitle('100')
573
+
574
+ expect(title).toBeNull()
575
+
576
+ client.close()
577
+ })
578
+
579
+ it('getChatTitle() returns null on invalid chatId without contacting LOCO', async () => {
580
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
581
+ const title = await client.getChatTitle('not-a-number')
582
+
583
+ expect(title).toBeNull()
584
+ expect(mockGetChannelInfo).not.toHaveBeenCalled()
585
+ expect(mockLogin).not.toHaveBeenCalled()
586
+
587
+ client.close()
588
+ })
589
+
590
+ it('falls back to INFOLINK link name for open chats with no TITLE meta', async () => {
591
+ const openChatLogin = {
592
+ chatDatas: [
593
+ {
594
+ c: 500,
595
+ t: 'OM',
596
+ li: makeLong(7777),
597
+ k: ['User1', 'User2'],
598
+ i: [10, 20],
599
+ a: 2,
600
+ n: 0,
601
+ o: 1700000000,
602
+ l: null,
603
+ ll: makeLong(1),
604
+ },
605
+ ],
606
+ lastTokenId: makeLong(0),
607
+ lastChatId: makeLong(0),
608
+ eof: true,
609
+ }
610
+ mockLogin.mockResolvedValue(openChatLogin)
611
+ mockGetChannelInfo.mockResolvedValueOnce({ body: { chatInfo: { chatMetas: [] } } })
612
+ mockGetOpenLinkInfo.mockResolvedValueOnce({ body: { ols: [{ ln: 'Open Group Title' }] } })
613
+
614
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
615
+ const title = await client.getChatTitle('500')
616
+
617
+ expect(title).toBe('Open Group Title')
618
+ expect(mockGetChannelInfo).toHaveBeenCalledTimes(1)
619
+ expect(mockGetOpenLinkInfo).toHaveBeenCalledTimes(1)
620
+
621
+ client.close()
622
+ })
623
+
624
+ it('does not call INFOLINK for non-open chats (regular MultiChat)', async () => {
625
+ mockGetChannelInfo.mockResolvedValueOnce({ body: { chatInfo: { chatMetas: [] } } })
626
+
627
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
628
+ const title = await client.getChatTitle('100')
629
+
630
+ expect(title).toBeNull()
631
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
632
+
633
+ client.close()
634
+ })
635
+
636
+ it('does not call INFOLINK when CHATINFO already returned a TITLE meta', async () => {
637
+ const openChatLogin = {
638
+ chatDatas: [
639
+ {
640
+ c: 500,
641
+ t: 'OM',
642
+ li: makeLong(7777),
643
+ k: [],
644
+ i: [],
645
+ a: 1,
646
+ n: 0,
647
+ o: 1700000000,
648
+ l: null,
649
+ ll: makeLong(1),
650
+ },
651
+ ],
652
+ lastTokenId: makeLong(0),
653
+ lastChatId: makeLong(0),
654
+ eof: true,
655
+ }
656
+ mockLogin.mockResolvedValue(openChatLogin)
657
+ mockGetChannelInfo.mockResolvedValueOnce({
658
+ body: { chatInfo: { chatMetas: [{ type: 3, content: 'User Set Title' }] } },
659
+ })
660
+
661
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
662
+ const title = await client.getChatTitle('500')
663
+
664
+ expect(title).toBe('User Set Title')
665
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
666
+
667
+ client.close()
668
+ })
669
+
670
+ it('returns null when INFOLINK fails for open chats', async () => {
671
+ const openChatLogin = {
672
+ chatDatas: [
673
+ {
674
+ c: 500,
675
+ t: 'OD',
676
+ li: makeLong(7777),
677
+ k: [],
678
+ i: [],
679
+ a: 1,
680
+ n: 0,
681
+ o: 1700000000,
682
+ l: null,
683
+ ll: makeLong(1),
684
+ },
685
+ ],
686
+ lastTokenId: makeLong(0),
687
+ lastChatId: makeLong(0),
688
+ eof: true,
689
+ }
690
+ mockLogin.mockResolvedValue(openChatLogin)
691
+ mockGetChannelInfo.mockResolvedValueOnce({ body: { chatInfo: { chatMetas: [] } } })
692
+ mockGetOpenLinkInfo.mockRejectedValueOnce(new Error('INFOLINK timeout'))
693
+
694
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
695
+ const title = await client.getChatTitle('500')
696
+
697
+ expect(title).toBeNull()
698
+
699
+ client.close()
700
+ })
701
+
702
+ it('returns null when open chat has no link id (li missing)', async () => {
703
+ const openChatLogin = {
704
+ chatDatas: [
705
+ {
706
+ c: 500,
707
+ t: 'OM',
708
+ k: [],
709
+ i: [],
710
+ a: 1,
711
+ n: 0,
712
+ o: 1700000000,
713
+ l: null,
714
+ ll: makeLong(1),
715
+ },
716
+ ],
717
+ lastTokenId: makeLong(0),
718
+ lastChatId: makeLong(0),
719
+ eof: true,
720
+ }
721
+ mockLogin.mockResolvedValue(openChatLogin)
722
+ mockGetChannelInfo.mockResolvedValueOnce({ body: { chatInfo: { chatMetas: [] } } })
723
+
724
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
725
+ const title = await client.getChatTitle('500')
726
+
727
+ expect(title).toBeNull()
728
+ expect(mockGetOpenLinkInfo).not.toHaveBeenCalled()
729
+
730
+ client.close()
731
+ })
732
+
307
733
  it('wraps getChatList failure as KakaoTalkError with code get_chats_failed', async () => {
308
734
  const loginResult = { ...DEFAULT_LOGIN_RESULT, eof: false }
309
735
  mockLogin.mockResolvedValue(loginResult)
@@ -343,6 +769,7 @@ describe('KakaoTalkClient', () => {
343
769
  log_id: '10',
344
770
  type: 1,
345
771
  author_id: 42,
772
+ author_name: null,
346
773
  message: 'hello',
347
774
  sent_at: 1700000001,
348
775
  })
@@ -350,6 +777,7 @@ describe('KakaoTalkClient', () => {
350
777
  log_id: '11',
351
778
  type: 1,
352
779
  author_id: 43,
780
+ author_name: null,
353
781
  message: 'world',
354
782
  sent_at: 1700000002,
355
783
  })
@@ -357,6 +785,31 @@ describe('KakaoTalkClient', () => {
357
785
  client.close()
358
786
  })
359
787
 
788
+ it('resolves author_name for known members from the chat list cache', async () => {
789
+ mockGetChatLogs.mockResolvedValueOnce({
790
+ body: {
791
+ status: 0,
792
+ chatLogs: [
793
+ { logId: makeLong(1), chatId: 100, type: 1, authorId: 1, message: 'from Alice', sendAt: 100 },
794
+ { logId: makeLong(2), chatId: 100, type: 1, authorId: 2, message: 'from Bob', sendAt: 200 },
795
+ { logId: makeLong(3), chatId: 100, type: 1, authorId: 99, message: 'from a stranger', sendAt: 300 },
796
+ ],
797
+ eof: true,
798
+ },
799
+ })
800
+
801
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
802
+ // Trigger login so the cache is populated from DEFAULT_LOGIN_RESULT
803
+ await client.getChats()
804
+ const messages = await client.getMessages('100')
805
+
806
+ expect(messages[0].author_name).toBe('Alice')
807
+ expect(messages[1].author_name).toBe('Bob')
808
+ expect(messages[2].author_name).toBeNull()
809
+
810
+ client.close()
811
+ })
812
+
360
813
  it('respects count option', async () => {
361
814
  const logs = Array.from({ length: 50 }, (_, i) => ({
362
815
  logId: makeLong(i + 1),
@@ -457,6 +910,337 @@ describe('KakaoTalkClient', () => {
457
910
  })
458
911
  })
459
912
 
913
+ describe('getMembers / getMembersByIds', () => {
914
+ it('returns formatted members from GETMEM with normalized fields', async () => {
915
+ mockGetAllMembers.mockResolvedValueOnce({
916
+ statusCode: 0,
917
+ body: {
918
+ members: [
919
+ {
920
+ userId: makeLong(42),
921
+ nickName: 'Alice',
922
+ type: 100,
923
+ profileImageUrl: 'https://kakao.com/p/alice.jpg',
924
+ fullProfileImageUrl: 'https://kakao.com/p/alice-full.jpg',
925
+ originalProfileImageUrl: 'https://kakao.com/p/alice-orig.jpg',
926
+ statusMessage: 'hi',
927
+ countryIso: 'KR',
928
+ },
929
+ {
930
+ userId: makeLong(43),
931
+ nickName: 'Bob',
932
+ type: 1000,
933
+ pi: 'https://kakao.com/p/bob.jpg',
934
+ fpi: 'https://kakao.com/p/bob-full.jpg',
935
+ opi: 'https://kakao.com/p/bob-orig.jpg',
936
+ opt: 12345,
937
+ pli: makeLong(99),
938
+ mt: 4,
939
+ },
940
+ ],
941
+ token: 0,
942
+ },
943
+ })
944
+
945
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
946
+ const members = await client.getMembers('100')
947
+
948
+ expect(members).toHaveLength(2)
949
+ expect(members[0]).toEqual({
950
+ user_id: '42',
951
+ nickname: 'Alice',
952
+ profile_image_url: 'https://kakao.com/p/alice.jpg',
953
+ full_profile_image_url: 'https://kakao.com/p/alice-full.jpg',
954
+ original_profile_image_url: 'https://kakao.com/p/alice-orig.jpg',
955
+ status_message: 'hi',
956
+ country_iso: 'KR',
957
+ user_type: 100,
958
+ open_token: null,
959
+ open_profile_link_id: null,
960
+ open_permission: null,
961
+ })
962
+ expect(members[1]).toEqual({
963
+ user_id: '43',
964
+ nickname: 'Bob',
965
+ profile_image_url: 'https://kakao.com/p/bob.jpg',
966
+ full_profile_image_url: 'https://kakao.com/p/bob-full.jpg',
967
+ original_profile_image_url: 'https://kakao.com/p/bob-orig.jpg',
968
+ status_message: null,
969
+ country_iso: null,
970
+ user_type: 1000,
971
+ open_token: 12345,
972
+ open_profile_link_id: '99',
973
+ open_permission: 4,
974
+ })
975
+
976
+ client.close()
977
+ })
978
+
979
+ it('returns empty array when GETMEM returns no members', async () => {
980
+ mockGetAllMembers.mockResolvedValueOnce({ statusCode: 0, body: {} })
981
+
982
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
983
+ const members = await client.getMembers('100')
984
+
985
+ expect(members).toEqual([])
986
+
987
+ client.close()
988
+ })
989
+
990
+ it('normalizes missing user_type to null and treats pli=0 as absent', async () => {
991
+ mockGetAllMembers.mockResolvedValueOnce({
992
+ statusCode: 0,
993
+ body: {
994
+ members: [
995
+ { userId: makeLong(1), nickName: 'NoType' },
996
+ { userId: makeLong(2), nickName: 'ZeroPli', type: 1000, pli: makeLong(0) },
997
+ { userId: makeLong(3), nickName: 'NumericZeroPli', type: 1000, pli: 0 },
998
+ ],
999
+ },
1000
+ })
1001
+
1002
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1003
+ const members = await client.getMembers('100')
1004
+
1005
+ expect(members[0].user_type).toBeNull()
1006
+ expect(members[0].open_profile_link_id).toBeNull()
1007
+ expect(members[1].user_type).toBe(1000)
1008
+ expect(members[1].open_profile_link_id).toBeNull()
1009
+ expect(members[2].open_profile_link_id).toBeNull()
1010
+
1011
+ client.close()
1012
+ })
1013
+
1014
+ it('wraps GETMEM failures as KakaoTalkError get_members_failed', async () => {
1015
+ mockGetAllMembers.mockRejectedValueOnce(new Error('Network error'))
1016
+
1017
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1018
+ try {
1019
+ await client.getMembers('100')
1020
+ expect.unreachable('should have thrown')
1021
+ } catch (e) {
1022
+ expect(e).toBeInstanceOf(KakaoTalkError)
1023
+ expect((e as KakaoTalkError).code).toBe('get_members_failed')
1024
+ }
1025
+
1026
+ client.close()
1027
+ })
1028
+
1029
+ it('throws on synthetic disconnect packet from GETMEM (statusCode != 0)', async () => {
1030
+ mockGetAllMembers.mockResolvedValueOnce({
1031
+ statusCode: -1,
1032
+ body: { error: 'connection closed' },
1033
+ })
1034
+
1035
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1036
+ try {
1037
+ await client.getMembers('100')
1038
+ expect.unreachable('should have thrown')
1039
+ } catch (e) {
1040
+ expect(e).toBeInstanceOf(KakaoTalkError)
1041
+ expect((e as KakaoTalkError).code).toBe('get_members_failed')
1042
+ }
1043
+
1044
+ client.close()
1045
+ })
1046
+
1047
+ it('throws on GETMEM body.status nonzero', async () => {
1048
+ mockGetAllMembers.mockResolvedValueOnce({
1049
+ statusCode: 0,
1050
+ body: { status: -500 },
1051
+ })
1052
+
1053
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1054
+ try {
1055
+ await client.getMembers('100')
1056
+ expect.unreachable('should have thrown')
1057
+ } catch (e) {
1058
+ expect(e).toBeInstanceOf(KakaoTalkError)
1059
+ expect((e as KakaoTalkError).code).toBe('get_members_failed')
1060
+ }
1061
+
1062
+ client.close()
1063
+ })
1064
+
1065
+ it('getMembersByIds passes parsed Long IDs to MEMBER request', async () => {
1066
+ mockGetMembersByIds.mockResolvedValueOnce({
1067
+ statusCode: 0,
1068
+ body: {
1069
+ chatId: makeLong(100),
1070
+ members: [{ userId: makeLong(42), nickName: 'Alice', type: 100 }],
1071
+ },
1072
+ })
1073
+
1074
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1075
+ const members = await client.getMembersByIds('100', ['42', '43', '99'])
1076
+
1077
+ expect(members).toHaveLength(1)
1078
+ expect(members[0].nickname).toBe('Alice')
1079
+ expect(mockGetMembersByIds).toHaveBeenCalledTimes(1)
1080
+
1081
+ // Verify the actual LOCO call args, not just the call count: chatId is a
1082
+ // Long-shaped { low, high } and memberIds is a list of Longs in the order
1083
+ // the SDK consumer passed them. Guards against accidentally sending raw
1084
+ // strings or losing IDs — both fail silently server-side.
1085
+ const [chatIdArg, memberIdsArg] = mockGetMembersByIds.mock.calls[0] as [
1086
+ { low: number; high: number },
1087
+ Array<{ low: number; high: number }>,
1088
+ ]
1089
+ expect(chatIdArg).toMatchObject({ low: 100, high: 0 })
1090
+ expect(memberIdsArg).toHaveLength(3)
1091
+ expect(memberIdsArg[0]).toMatchObject({ low: 42, high: 0 })
1092
+ expect(memberIdsArg[1]).toMatchObject({ low: 43, high: 0 })
1093
+ expect(memberIdsArg[2]).toMatchObject({ low: 99, high: 0 })
1094
+
1095
+ client.close()
1096
+ })
1097
+
1098
+ it('wraps MEMBER failures as KakaoTalkError get_members_failed', async () => {
1099
+ mockGetMembersByIds.mockRejectedValueOnce(new Error('Network error'))
1100
+
1101
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1102
+ try {
1103
+ await client.getMembersByIds('100', ['42'])
1104
+ expect.unreachable('should have thrown')
1105
+ } catch (e) {
1106
+ expect(e).toBeInstanceOf(KakaoTalkError)
1107
+ expect((e as KakaoTalkError).code).toBe('get_members_failed')
1108
+ }
1109
+
1110
+ client.close()
1111
+ })
1112
+
1113
+ it('throws on synthetic disconnect packet from MEMBER (statusCode != 0)', async () => {
1114
+ mockGetMembersByIds.mockResolvedValueOnce({
1115
+ statusCode: -1,
1116
+ body: { error: 'connection closed' },
1117
+ })
1118
+
1119
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1120
+ try {
1121
+ await client.getMembersByIds('100', ['42'])
1122
+ expect.unreachable('should have thrown')
1123
+ } catch (e) {
1124
+ expect(e).toBeInstanceOf(KakaoTalkError)
1125
+ expect((e as KakaoTalkError).code).toBe('get_members_failed')
1126
+ }
1127
+
1128
+ client.close()
1129
+ })
1130
+
1131
+ it('throws on MEMBER body.status nonzero', async () => {
1132
+ mockGetMembersByIds.mockResolvedValueOnce({
1133
+ statusCode: 0,
1134
+ body: { status: -500 },
1135
+ })
1136
+
1137
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1138
+ try {
1139
+ await client.getMembersByIds('100', ['42'])
1140
+ expect.unreachable('should have thrown')
1141
+ } catch (e) {
1142
+ expect(e).toBeInstanceOf(KakaoTalkError)
1143
+ expect((e as KakaoTalkError).code).toBe('get_members_failed')
1144
+ }
1145
+
1146
+ client.close()
1147
+ })
1148
+
1149
+ it('getMembers throws KakaoTalkError invalid_chat_id without contacting LOCO', async () => {
1150
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1151
+ try {
1152
+ await client.getMembers('not-a-number')
1153
+ expect.unreachable('should have thrown')
1154
+ } catch (e) {
1155
+ expect(e).toBeInstanceOf(KakaoTalkError)
1156
+ expect((e as KakaoTalkError).code).toBe('invalid_chat_id')
1157
+ }
1158
+ expect(mockGetAllMembers).not.toHaveBeenCalled()
1159
+ expect(mockLogin).not.toHaveBeenCalled()
1160
+
1161
+ client.close()
1162
+ })
1163
+
1164
+ it('getMembersByIds throws invalid_chat_id for bad chatId without contacting LOCO', async () => {
1165
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1166
+ try {
1167
+ await client.getMembersByIds('not-a-number', ['42'])
1168
+ expect.unreachable('should have thrown')
1169
+ } catch (e) {
1170
+ expect(e).toBeInstanceOf(KakaoTalkError)
1171
+ expect((e as KakaoTalkError).code).toBe('invalid_chat_id')
1172
+ }
1173
+ expect(mockGetMembersByIds).not.toHaveBeenCalled()
1174
+ expect(mockLogin).not.toHaveBeenCalled()
1175
+
1176
+ client.close()
1177
+ })
1178
+
1179
+ it('getMembersByIds throws invalid_user_id for bad userId without contacting LOCO', async () => {
1180
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1181
+ try {
1182
+ await client.getMembersByIds('100', ['42', 'bogus', '99'])
1183
+ expect.unreachable('should have thrown')
1184
+ } catch (e) {
1185
+ expect(e).toBeInstanceOf(KakaoTalkError)
1186
+ expect((e as KakaoTalkError).code).toBe('invalid_user_id')
1187
+ }
1188
+ expect(mockGetMembersByIds).not.toHaveBeenCalled()
1189
+ expect(mockLogin).not.toHaveBeenCalled()
1190
+
1191
+ client.close()
1192
+ })
1193
+
1194
+ it('getMembersByIds returns [] without contacting LOCO when userIds is empty', async () => {
1195
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1196
+ const result = await client.getMembersByIds('100', [])
1197
+
1198
+ expect(result).toEqual([])
1199
+ expect(mockGetMembersByIds).not.toHaveBeenCalled()
1200
+ expect(mockLogin).not.toHaveBeenCalled()
1201
+
1202
+ client.close()
1203
+ })
1204
+
1205
+ it('reconnects and retries getMembers after silent disconnect (full executeWithReconnect path)', async () => {
1206
+ // Earlier tests verify assertLocoOk throws on a synthetic-disconnect packet,
1207
+ // but they don't exercise the reconnect path: executeWithReconnect only
1208
+ // retries when the underlying socket also closed (state.session !== this.state.session).
1209
+ // This test fires the captured onClose callback before the rejection so the
1210
+ // client's state is nulled, then verifies the operation is retried against
1211
+ // a fresh session and the second response is returned to the caller.
1212
+ const closeHandlers: Array<() => void> = []
1213
+ mockOnClose.mockImplementation((handler: () => void) => {
1214
+ closeHandlers.push(handler)
1215
+ })
1216
+
1217
+ mockGetAllMembers.mockImplementationOnce(() => {
1218
+ // Fire the close handler captured during the first session's setup —
1219
+ // simulates the LOCO TCP socket closing while the GETMEM is in flight.
1220
+ // executeWithReconnect sees state.session !== this.state.session and retries.
1221
+ const handler = closeHandlers[0]
1222
+ if (handler) handler()
1223
+ return Promise.resolve({ statusCode: -1, body: { error: 'connection closed' } })
1224
+ })
1225
+ mockGetAllMembers.mockResolvedValueOnce({
1226
+ statusCode: 0,
1227
+ body: { members: [{ userId: makeLong(42), nickName: 'Alice', type: 100 }] },
1228
+ })
1229
+
1230
+ const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
1231
+ const members = await client.getMembers('100')
1232
+
1233
+ expect(members).toHaveLength(1)
1234
+ expect(members[0].nickname).toBe('Alice')
1235
+ expect(mockGetAllMembers).toHaveBeenCalledTimes(2)
1236
+ // Login fires twice: once for the initial connect, once for the reconnect
1237
+ // after the captured onClose handler invalidated this.state.
1238
+ expect(mockLogin).toHaveBeenCalledTimes(2)
1239
+
1240
+ client.close()
1241
+ })
1242
+ })
1243
+
460
1244
  describe('getProfile', () => {
461
1245
  const mockFetch = mock(() => Promise.resolve(new Response()))
462
1246