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.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +18 -0
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/chat-classifier.js +29 -0
- package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -0
- package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/cli.js +2 -1
- package/dist/src/platforms/kakaotalk/cli.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +35 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +318 -15
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/chat.js +2 -1
- package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/index.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/index.js +1 -0
- package/dist/src/platforms/kakaotalk/commands/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/member.d.ts +3 -0
- package/dist/src/platforms/kakaotalk/commands/member.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/commands/member.js +22 -0
- package/dist/src/platforms/kakaotalk/commands/member.js.map +1 -0
- package/dist/src/platforms/kakaotalk/index.d.ts +4 -2
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +2 -1
- package/dist/src/platforms/kakaotalk/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.d.ts +4 -7
- package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.js +48 -74
- package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +28 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +44 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +37 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +17 -0
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/platforms/slackbot/client.d.ts +5 -0
- package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
- package/dist/src/platforms/slackbot/client.js +5 -0
- package/dist/src/platforms/slackbot/client.js.map +1 -1
- package/dist/src/tui/adapters/kakaotalk-adapter.js +3 -3
- package/dist/src/tui/adapters/kakaotalk-adapter.js.map +1 -1
- package/docs/content/docs/cli/kakaotalk.mdx +26 -1
- package/docs/content/docs/sdk/kakaotalk.mdx +45 -13
- package/docs/content/docs/sdk/slackbot.mdx +11 -0
- package/package.json +1 -1
- package/scripts/kakao-loco-capture.ts +466 -0
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +30 -3
- package/skills/agent-kakaotalk/references/common-patterns.md +49 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -2
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/platforms/kakaotalk/chat-classifier.test.ts +33 -0
- package/src/platforms/kakaotalk/chat-classifier.ts +31 -0
- package/src/platforms/kakaotalk/cli.ts +2 -1
- package/src/platforms/kakaotalk/client-listener-integration.test.ts +411 -0
- package/src/platforms/kakaotalk/client.test.ts +785 -1
- package/src/platforms/kakaotalk/client.ts +369 -18
- package/src/platforms/kakaotalk/commands/chat.ts +3 -1
- package/src/platforms/kakaotalk/commands/index.ts +1 -0
- package/src/platforms/kakaotalk/commands/member.test.ts +102 -0
- package/src/platforms/kakaotalk/commands/member.ts +32 -0
- package/src/platforms/kakaotalk/index.test.ts +5 -0
- package/src/platforms/kakaotalk/index.ts +4 -0
- package/src/platforms/kakaotalk/listener.test.ts +184 -149
- package/src/platforms/kakaotalk/listener.ts +51 -82
- package/src/platforms/kakaotalk/protocol/session.ts +44 -0
- package/src/platforms/kakaotalk/types.ts +39 -0
- package/src/platforms/slackbot/client.test.ts +67 -0
- package/src/platforms/slackbot/client.ts +17 -1
- 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
|
|