agent-messenger 2.12.2 → 2.13.1

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 (104) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/discordbot/index.d.ts +2 -1
  4. package/dist/src/platforms/discordbot/index.d.ts.map +1 -1
  5. package/dist/src/platforms/discordbot/index.js.map +1 -1
  6. package/dist/src/platforms/discordbot/listener.d.ts +4 -3
  7. package/dist/src/platforms/discordbot/listener.d.ts.map +1 -1
  8. package/dist/src/platforms/discordbot/listener.js.map +1 -1
  9. package/dist/src/platforms/discordbot/types.d.ts +21 -0
  10. package/dist/src/platforms/discordbot/types.d.ts.map +1 -1
  11. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +18 -0
  12. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -0
  13. package/dist/src/platforms/kakaotalk/chat-classifier.js +29 -0
  14. package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -0
  15. package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
  16. package/dist/src/platforms/kakaotalk/cli.js +2 -1
  17. package/dist/src/platforms/kakaotalk/cli.js.map +1 -1
  18. package/dist/src/platforms/kakaotalk/client.d.ts +13 -1
  19. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  20. package/dist/src/platforms/kakaotalk/client.js +225 -8
  21. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  22. package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
  23. package/dist/src/platforms/kakaotalk/commands/chat.js +2 -1
  24. package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
  25. package/dist/src/platforms/kakaotalk/commands/index.d.ts +1 -0
  26. package/dist/src/platforms/kakaotalk/commands/index.d.ts.map +1 -1
  27. package/dist/src/platforms/kakaotalk/commands/index.js +1 -0
  28. package/dist/src/platforms/kakaotalk/commands/index.js.map +1 -1
  29. package/dist/src/platforms/kakaotalk/commands/member.d.ts +3 -0
  30. package/dist/src/platforms/kakaotalk/commands/member.d.ts.map +1 -0
  31. package/dist/src/platforms/kakaotalk/commands/member.js +22 -0
  32. package/dist/src/platforms/kakaotalk/commands/member.js.map +1 -0
  33. package/dist/src/platforms/kakaotalk/index.d.ts +4 -2
  34. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  35. package/dist/src/platforms/kakaotalk/index.js +2 -1
  36. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  37. package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
  38. package/dist/src/platforms/kakaotalk/listener.js +5 -2
  39. package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
  40. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +28 -0
  41. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  42. package/dist/src/platforms/kakaotalk/protocol/session.js +44 -0
  43. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  44. package/dist/src/platforms/kakaotalk/types.d.ts +37 -0
  45. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  46. package/dist/src/platforms/kakaotalk/types.js +17 -0
  47. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  48. package/dist/src/platforms/slackbot/client.d.ts +5 -0
  49. package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
  50. package/dist/src/platforms/slackbot/client.js +5 -0
  51. package/dist/src/platforms/slackbot/client.js.map +1 -1
  52. package/dist/src/platforms/slackbot/index.d.ts +2 -2
  53. package/dist/src/platforms/slackbot/index.d.ts.map +1 -1
  54. package/dist/src/platforms/slackbot/index.js +1 -1
  55. package/dist/src/platforms/slackbot/index.js.map +1 -1
  56. package/dist/src/tui/adapters/kakaotalk-adapter.js +3 -3
  57. package/dist/src/tui/adapters/kakaotalk-adapter.js.map +1 -1
  58. package/docs/content/docs/cli/kakaotalk.mdx +26 -1
  59. package/docs/content/docs/sdk/kakaotalk.mdx +45 -13
  60. package/docs/content/docs/sdk/slackbot.mdx +11 -0
  61. package/package.json +1 -1
  62. package/scripts/kakao-loco-capture.ts +466 -0
  63. package/skills/agent-channeltalk/SKILL.md +1 -1
  64. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  65. package/skills/agent-discord/SKILL.md +1 -1
  66. package/skills/agent-discordbot/SKILL.md +1 -1
  67. package/skills/agent-instagram/SKILL.md +1 -1
  68. package/skills/agent-kakaotalk/SKILL.md +30 -3
  69. package/skills/agent-kakaotalk/references/common-patterns.md +49 -1
  70. package/skills/agent-line/SKILL.md +1 -1
  71. package/skills/agent-slack/SKILL.md +1 -1
  72. package/skills/agent-slackbot/SKILL.md +1 -2
  73. package/skills/agent-teams/SKILL.md +1 -1
  74. package/skills/agent-telegram/SKILL.md +1 -1
  75. package/skills/agent-telegrambot/SKILL.md +1 -1
  76. package/skills/agent-webex/SKILL.md +1 -1
  77. package/skills/agent-wechatbot/SKILL.md +1 -1
  78. package/skills/agent-whatsapp/SKILL.md +1 -1
  79. package/skills/agent-whatsappbot/SKILL.md +1 -1
  80. package/src/platforms/discordbot/index.test.ts +76 -0
  81. package/src/platforms/discordbot/index.ts +3 -0
  82. package/src/platforms/discordbot/listener.test.ts +41 -0
  83. package/src/platforms/discordbot/listener.ts +5 -1
  84. package/src/platforms/discordbot/types.ts +23 -1
  85. package/src/platforms/kakaotalk/chat-classifier.test.ts +33 -0
  86. package/src/platforms/kakaotalk/chat-classifier.ts +31 -0
  87. package/src/platforms/kakaotalk/cli.ts +2 -1
  88. package/src/platforms/kakaotalk/client.test.ts +782 -1
  89. package/src/platforms/kakaotalk/client.ts +262 -10
  90. package/src/platforms/kakaotalk/commands/chat.ts +3 -1
  91. package/src/platforms/kakaotalk/commands/index.ts +1 -0
  92. package/src/platforms/kakaotalk/commands/member.test.ts +102 -0
  93. package/src/platforms/kakaotalk/commands/member.ts +32 -0
  94. package/src/platforms/kakaotalk/index.test.ts +5 -0
  95. package/src/platforms/kakaotalk/index.ts +4 -0
  96. package/src/platforms/kakaotalk/listener.test.ts +29 -0
  97. package/src/platforms/kakaotalk/listener.ts +5 -2
  98. package/src/platforms/kakaotalk/protocol/session.ts +44 -0
  99. package/src/platforms/kakaotalk/types.ts +39 -0
  100. package/src/platforms/slackbot/client.test.ts +67 -0
  101. package/src/platforms/slackbot/client.ts +17 -1
  102. package/src/platforms/slackbot/index.test.ts +10 -0
  103. package/src/platforms/slackbot/index.ts +4 -0
  104. package/src/tui/adapters/kakaotalk-adapter.ts +3 -3
@@ -7,6 +7,10 @@ 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(() => {})
@@ -19,6 +23,10 @@ mock.module('./protocol/session', () => ({
19
23
  getChatList = mockGetChatList
20
24
  getChatLogs = mockGetChatLogs
21
25
  getChatInfo = mockGetChatInfo
26
+ getChannelInfo = mockGetChannelInfo
27
+ getOpenLinkInfo = mockGetOpenLinkInfo
28
+ getAllMembers = mockGetAllMembers
29
+ getMembersByIds = mockGetMembersByIds
22
30
  syncMessages = mockSyncMessages
23
31
  sendMessage = mockSendMessage
24
32
  close = mockClose
@@ -36,6 +44,10 @@ function resetAllMocks() {
36
44
  mockGetChatList.mockReset()
37
45
  mockGetChatLogs.mockReset()
38
46
  mockGetChatInfo.mockReset()
47
+ mockGetChannelInfo.mockReset()
48
+ mockGetOpenLinkInfo.mockReset()
49
+ mockGetAllMembers.mockReset()
50
+ mockGetMembersByIds.mockReset()
39
51
  mockSyncMessages.mockReset()
40
52
  mockSendMessage.mockReset()
41
53
  mockClose.mockReset()
@@ -43,13 +55,15 @@ function resetAllMocks() {
43
55
  mockOnPush.mockReset()
44
56
  }
45
57
 
46
- // 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.
47
60
  const DEFAULT_LOGIN_RESULT = {
48
61
  chatDatas: [
49
62
  {
50
63
  c: 100,
51
64
  t: 1,
52
65
  k: ['Alice', 'Bob'],
66
+ i: [1, 2],
53
67
  a: 2,
54
68
  n: 3,
55
69
  o: 1700000000,
@@ -60,6 +74,7 @@ const DEFAULT_LOGIN_RESULT = {
60
74
  c: 200,
61
75
  t: 2,
62
76
  k: ['Charlie'],
77
+ i: [3],
63
78
  a: 1,
64
79
  n: 0,
65
80
  o: 1699999000,
@@ -123,19 +138,248 @@ describe('KakaoTalkClient', () => {
123
138
 
124
139
  expect(chats).toHaveLength(2)
125
140
  expect(chats[0].display_name).toBe('Alice, Bob')
141
+ expect(chats[0].title).toBeNull()
126
142
  expect(chats[0].active_members).toBe(2)
127
143
  expect(chats[0].unread_count).toBe(3)
128
144
  expect(chats[0].last_message).toEqual({
129
145
  author_id: 1,
146
+ author_name: 'Alice',
130
147
  message: 'hi',
131
148
  sent_at: 1700000000,
132
149
  })
133
150
  expect(chats[1].display_name).toBe('Charlie')
151
+ expect(chats[1].title).toBeNull()
134
152
  expect(chats[1].last_message).toBeNull()
135
153
 
136
154
  client.close()
137
155
  })
138
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
+
139
383
  it('sorts chats by recency (o field descending)', async () => {
140
384
  const client = await new KakaoTalkClient().login({ oauthToken: 'token', userId: 'user1', deviceUuid: 'device1' })
141
385
  const chats = await client.getChats()
@@ -307,6 +551,185 @@ describe('KakaoTalkClient', () => {
307
551
  client.close()
308
552
  })
309
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
+
310
733
  it('wraps getChatList failure as KakaoTalkError with code get_chats_failed', async () => {
311
734
  const loginResult = { ...DEFAULT_LOGIN_RESULT, eof: false }
312
735
  mockLogin.mockResolvedValue(loginResult)
@@ -346,6 +769,7 @@ describe('KakaoTalkClient', () => {
346
769
  log_id: '10',
347
770
  type: 1,
348
771
  author_id: 42,
772
+ author_name: null,
349
773
  message: 'hello',
350
774
  sent_at: 1700000001,
351
775
  })
@@ -353,6 +777,7 @@ describe('KakaoTalkClient', () => {
353
777
  log_id: '11',
354
778
  type: 1,
355
779
  author_id: 43,
780
+ author_name: null,
356
781
  message: 'world',
357
782
  sent_at: 1700000002,
358
783
  })
@@ -360,6 +785,31 @@ describe('KakaoTalkClient', () => {
360
785
  client.close()
361
786
  })
362
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
+
363
813
  it('respects count option', async () => {
364
814
  const logs = Array.from({ length: 50 }, (_, i) => ({
365
815
  logId: makeLong(i + 1),
@@ -460,6 +910,337 @@ describe('KakaoTalkClient', () => {
460
910
  })
461
911
  })
462
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
+
463
1244
  describe('getProfile', () => {
464
1245
  const mockFetch = mock(() => Promise.resolve(new Response()))
465
1246