agent-messenger 2.12.1 → 2.12.2
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/client.d.ts +22 -0
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +93 -7
- package/dist/src/platforms/kakaotalk/client.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 +43 -72
- package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
- package/package.json +1 -1
- 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 +1 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- 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/client-listener-integration.test.ts +411 -0
- package/src/platforms/kakaotalk/client.test.ts +3 -0
- package/src/platforms/kakaotalk/client.ts +108 -9
- package/src/platforms/kakaotalk/listener.test.ts +155 -149
- package/src/platforms/kakaotalk/listener.ts +46 -80
|
@@ -9,9 +9,17 @@ import { warn } from '@/shared/utils/stderr'
|
|
|
9
9
|
|
|
10
10
|
import { LANG, PC_OS_NAME, getLocoDeviceConfig } from './protocol/config'
|
|
11
11
|
import { LocoSession } from './protocol/session'
|
|
12
|
-
import type { ChatListResponse, LoginListResponse, SyncState } from './protocol/types'
|
|
12
|
+
import type { ChatListResponse, LocoPacket, LoginListResponse, SyncState } from './protocol/types'
|
|
13
13
|
import type { KakaoChat, KakaoDeviceType, KakaoMessage, KakaoProfile, KakaoSendResult } from './types'
|
|
14
14
|
|
|
15
|
+
export type KakaoSessionEvent =
|
|
16
|
+
| { type: 'connected'; userId: string }
|
|
17
|
+
| { type: 'disconnected' }
|
|
18
|
+
| { type: 'kicked'; reason: string }
|
|
19
|
+
|
|
20
|
+
export type KakaoPushHandler = (packet: LocoPacket) => void
|
|
21
|
+
export type KakaoSessionEventHandler = (event: KakaoSessionEvent) => void
|
|
22
|
+
|
|
15
23
|
export class KakaoTalkError extends Error {
|
|
16
24
|
code: string
|
|
17
25
|
|
|
@@ -234,6 +242,8 @@ export class KakaoTalkClient {
|
|
|
234
242
|
private state: SessionState | null = null
|
|
235
243
|
private initPromise: Promise<SessionState> | null = null
|
|
236
244
|
private closed = false
|
|
245
|
+
private pushHandlers = new Set<KakaoPushHandler>()
|
|
246
|
+
private sessionEventHandlers = new Set<KakaoSessionEventHandler>()
|
|
237
247
|
|
|
238
248
|
async login(
|
|
239
249
|
credentials?: { oauthToken: string; userId: string; deviceUuid?: string; deviceType?: KakaoDeviceType },
|
|
@@ -280,6 +290,7 @@ export class KakaoTalkClient {
|
|
|
280
290
|
if (this.state) return this.state
|
|
281
291
|
|
|
282
292
|
// Guard against concurrent init — reuse the in-flight promise
|
|
293
|
+
const isOwner = !this.initPromise
|
|
283
294
|
if (!this.initPromise) {
|
|
284
295
|
this.initPromise = this.connect()
|
|
285
296
|
}
|
|
@@ -291,7 +302,11 @@ export class KakaoTalkClient {
|
|
|
291
302
|
state.session.close()
|
|
292
303
|
throw new KakaoTalkError('Client is closed', 'client_closed')
|
|
293
304
|
}
|
|
305
|
+
const wasNew = this.state !== state
|
|
294
306
|
this.state = state
|
|
307
|
+
if (isOwner && wasNew) {
|
|
308
|
+
this.emitSessionEvent({ type: 'connected', userId: this.userId! })
|
|
309
|
+
}
|
|
295
310
|
return state
|
|
296
311
|
} catch (error) {
|
|
297
312
|
// Reset so next call retries cleanly; connect() already wraps in KakaoTalkError
|
|
@@ -301,6 +316,29 @@ export class KakaoTalkClient {
|
|
|
301
316
|
}
|
|
302
317
|
}
|
|
303
318
|
|
|
319
|
+
async acquireSession(): Promise<LocoSession> {
|
|
320
|
+
const state = await this.ensureSession()
|
|
321
|
+
return state.session
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
onPush(handler: KakaoPushHandler): () => void {
|
|
325
|
+
this.pushHandlers.add(handler)
|
|
326
|
+
return () => {
|
|
327
|
+
this.pushHandlers.delete(handler)
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
onSessionEvent(handler: KakaoSessionEventHandler): () => void {
|
|
332
|
+
this.sessionEventHandlers.add(handler)
|
|
333
|
+
return () => {
|
|
334
|
+
this.sessionEventHandlers.delete(handler)
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
isConnected(): boolean {
|
|
339
|
+
return this.state !== null && !this.closed
|
|
340
|
+
}
|
|
341
|
+
|
|
304
342
|
private async executeWithReconnect<T>(operation: (state: SessionState) => Promise<T>): Promise<T> {
|
|
305
343
|
let state = await this.ensureSession()
|
|
306
344
|
try {
|
|
@@ -314,7 +352,10 @@ export class KakaoTalkClient {
|
|
|
314
352
|
try {
|
|
315
353
|
state.session.close()
|
|
316
354
|
} catch {}
|
|
317
|
-
|
|
355
|
+
// initPromise is intentionally NOT cleared here: a concurrent caller may already
|
|
356
|
+
// be awaiting an in-flight replacement, and starting a parallel one would send a
|
|
357
|
+
// second LOGINLIST with the same duuid — re-introducing the very self-eviction
|
|
358
|
+
// this layer prevents. Lifecycle paths (onClose / invalidateSession) own that field.
|
|
318
359
|
state = await this.ensureSession()
|
|
319
360
|
return operation(state)
|
|
320
361
|
}
|
|
@@ -322,6 +363,15 @@ export class KakaoTalkClient {
|
|
|
322
363
|
|
|
323
364
|
private async connect(): Promise<SessionState> {
|
|
324
365
|
const session = new LocoSession()
|
|
366
|
+
session.onPush((packet) => this.dispatchPush(session, packet))
|
|
367
|
+
session.onClose(() => {
|
|
368
|
+
if (this.state?.session === session) {
|
|
369
|
+
this.state = null
|
|
370
|
+
this.initPromise = null
|
|
371
|
+
this.emitSessionEvent({ type: 'disconnected' })
|
|
372
|
+
}
|
|
373
|
+
})
|
|
374
|
+
|
|
325
375
|
try {
|
|
326
376
|
const syncState = await loadSyncState(this.deviceUuid!)
|
|
327
377
|
const loginResult = await session.login(
|
|
@@ -335,13 +385,6 @@ export class KakaoTalkClient {
|
|
|
335
385
|
const newSyncState = mergeSyncState(syncState, loginResult)
|
|
336
386
|
await saveSyncState(this.deviceUuid!, newSyncState)
|
|
337
387
|
|
|
338
|
-
session.onClose(() => {
|
|
339
|
-
if (this.state?.session === session) {
|
|
340
|
-
this.state = null
|
|
341
|
-
this.initPromise = null
|
|
342
|
-
}
|
|
343
|
-
})
|
|
344
|
-
|
|
345
388
|
return { session, loginResult }
|
|
346
389
|
} catch (error) {
|
|
347
390
|
session.close()
|
|
@@ -349,6 +392,60 @@ export class KakaoTalkClient {
|
|
|
349
392
|
}
|
|
350
393
|
}
|
|
351
394
|
|
|
395
|
+
private dispatchPush(session: LocoSession, packet: LocoPacket): void {
|
|
396
|
+
// Only fan out pushes from the currently adopted session. While state is null
|
|
397
|
+
// (pre-adoption during connect, or post-invalidation during reconnect) the
|
|
398
|
+
// packet is discarded — we never want a not-yet-adopted or already-dead session
|
|
399
|
+
// to reach subscribers and look "live".
|
|
400
|
+
if (this.state?.session !== session) return
|
|
401
|
+
|
|
402
|
+
if (packet.method === 'KICKOUT') {
|
|
403
|
+
this.emitSessionEvent({ type: 'kicked', reason: 'Session kicked — another device logged in' })
|
|
404
|
+
this.invalidateSession(session)
|
|
405
|
+
return
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
if (packet.method === 'CHANGESVR') {
|
|
409
|
+
for (const handler of this.pushHandlers) {
|
|
410
|
+
try {
|
|
411
|
+
handler(packet)
|
|
412
|
+
} catch {}
|
|
413
|
+
}
|
|
414
|
+
this.invalidateSession(session)
|
|
415
|
+
this.emitSessionEvent({ type: 'disconnected' })
|
|
416
|
+
this.ensureSession().catch(() => {
|
|
417
|
+
// ensureSession already cleared state on failure; subsequent API calls will retry
|
|
418
|
+
// and surface the error. Listeners do not receive 'connected' until a reconnect
|
|
419
|
+
// succeeds, which is the correct outcome.
|
|
420
|
+
})
|
|
421
|
+
return
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
for (const handler of this.pushHandlers) {
|
|
425
|
+
try {
|
|
426
|
+
handler(packet)
|
|
427
|
+
} catch {}
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
private invalidateSession(session: LocoSession): void {
|
|
432
|
+
if (this.state?.session === session) {
|
|
433
|
+
this.state = null
|
|
434
|
+
this.initPromise = null
|
|
435
|
+
}
|
|
436
|
+
try {
|
|
437
|
+
session.close()
|
|
438
|
+
} catch {}
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
private emitSessionEvent(event: KakaoSessionEvent): void {
|
|
442
|
+
for (const handler of this.sessionEventHandlers) {
|
|
443
|
+
try {
|
|
444
|
+
handler(event)
|
|
445
|
+
} catch {}
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
|
|
352
449
|
async getChats(options?: { all?: boolean; search?: string }): Promise<KakaoChat[]> {
|
|
353
450
|
return this.executeWithReconnect(async ({ session, loginResult }) => {
|
|
354
451
|
try {
|
|
@@ -584,5 +681,7 @@ export class KakaoTalkClient {
|
|
|
584
681
|
}
|
|
585
682
|
this.state = null
|
|
586
683
|
this.initPromise = null
|
|
684
|
+
this.pushHandlers.clear()
|
|
685
|
+
this.sessionEventHandlers.clear()
|
|
587
686
|
}
|
|
588
687
|
}
|
|
@@ -1,59 +1,61 @@
|
|
|
1
|
-
import { afterEach, describe, expect,
|
|
1
|
+
import { afterEach, describe, expect, it } from 'bun:test'
|
|
2
2
|
|
|
3
|
-
import {
|
|
4
|
-
import
|
|
3
|
+
import type { KakaoSessionEvent, KakaoSessionEventHandler, KakaoPushHandler, KakaoTalkClient } from './client'
|
|
4
|
+
import { KakaoTalkListener } from './listener'
|
|
5
|
+
import type { LocoPacket } from './protocol/types'
|
|
5
6
|
import type {
|
|
6
7
|
KakaoTalkPushGenericEvent,
|
|
7
8
|
KakaoTalkPushMemberEvent,
|
|
8
9
|
KakaoTalkPushMessageEvent,
|
|
9
10
|
KakaoTalkPushReadEvent,
|
|
10
|
-
} from '
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
11
|
+
} from './types'
|
|
12
|
+
|
|
13
|
+
class FakeClient {
|
|
14
|
+
acquireCalls = 0
|
|
15
|
+
pushHandlers = new Set<KakaoPushHandler>()
|
|
16
|
+
sessionHandlers = new Set<KakaoSessionEventHandler>()
|
|
17
|
+
acquireImpl: () => Promise<void> = async () => {}
|
|
18
|
+
connected = false
|
|
19
|
+
|
|
20
|
+
async acquireSession(): Promise<unknown> {
|
|
21
|
+
this.acquireCalls++
|
|
22
|
+
await this.acquireImpl()
|
|
23
|
+
this.connected = true
|
|
24
|
+
return {}
|
|
25
|
+
}
|
|
16
26
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
login = mockLogin
|
|
21
|
-
close = mockSessionClose
|
|
27
|
+
isConnected(): boolean {
|
|
28
|
+
return this.connected
|
|
29
|
+
}
|
|
22
30
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
mockSessionInstance = this
|
|
31
|
+
getCredentials(): { oauthToken: string; userId: string; deviceUuid: string; deviceType: 'tablet' } {
|
|
32
|
+
return { oauthToken: 'token', userId: 'user1', deviceUuid: 'device1', deviceType: 'tablet' }
|
|
26
33
|
}
|
|
27
34
|
|
|
28
|
-
onPush(handler:
|
|
29
|
-
this.
|
|
35
|
+
onPush(handler: KakaoPushHandler): () => void {
|
|
36
|
+
this.pushHandlers.add(handler)
|
|
37
|
+
return () => this.pushHandlers.delete(handler)
|
|
30
38
|
}
|
|
31
39
|
|
|
32
|
-
|
|
33
|
-
this.
|
|
40
|
+
onSessionEvent(handler: KakaoSessionEventHandler): () => void {
|
|
41
|
+
this.sessionHandlers.add(handler)
|
|
42
|
+
return () => this.sessionHandlers.delete(handler)
|
|
34
43
|
}
|
|
35
44
|
|
|
36
|
-
|
|
37
|
-
|
|
45
|
+
emitPush(method: string, body: Record<string, unknown>): void {
|
|
46
|
+
const packet: LocoPacket = { packetId: 0, statusCode: 0, method, bodyType: 0, body }
|
|
47
|
+
for (const handler of this.pushHandlers) handler(packet)
|
|
38
48
|
}
|
|
39
49
|
|
|
40
|
-
|
|
41
|
-
this.
|
|
50
|
+
emitSessionEvent(event: KakaoSessionEvent): void {
|
|
51
|
+
for (const handler of this.sessionHandlers) handler(event)
|
|
42
52
|
}
|
|
43
53
|
}
|
|
44
54
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
return {
|
|
49
|
-
getCredentials: mock(() => ({
|
|
50
|
-
oauthToken: 'token',
|
|
51
|
-
userId: 'user1',
|
|
52
|
-
deviceUuid: 'device1',
|
|
53
|
-
deviceType: 'tablet' as const,
|
|
54
|
-
})),
|
|
55
|
-
...overrides,
|
|
56
|
-
} as any
|
|
55
|
+
function createListener(overrides: Partial<FakeClient> = {}): { listener: KakaoTalkListener; client: FakeClient } {
|
|
56
|
+
const client = Object.assign(new FakeClient(), overrides)
|
|
57
|
+
const listener = new KakaoTalkListener(client as unknown as KakaoTalkClient)
|
|
58
|
+
return { listener, client }
|
|
57
59
|
}
|
|
58
60
|
|
|
59
61
|
describe('KakaoTalkListener', () => {
|
|
@@ -61,58 +63,69 @@ describe('KakaoTalkListener', () => {
|
|
|
61
63
|
|
|
62
64
|
afterEach(() => {
|
|
63
65
|
listener?.stop()
|
|
64
|
-
mockLogin.mockReset()
|
|
65
|
-
mockLogin.mockResolvedValue({})
|
|
66
|
-
mockSessionClose.mockReset()
|
|
67
66
|
})
|
|
68
67
|
|
|
69
68
|
describe('start', () => {
|
|
70
|
-
it('
|
|
71
|
-
const client =
|
|
72
|
-
listener =
|
|
69
|
+
it('acquires the shared session from the client', async () => {
|
|
70
|
+
const { listener: l, client } = createListener()
|
|
71
|
+
listener = l
|
|
73
72
|
|
|
74
73
|
await listener.start()
|
|
75
74
|
|
|
76
|
-
expect(
|
|
77
|
-
expect(mockLogin).toHaveBeenCalledWith('token', 'user1', 'device1', undefined, 'tablet')
|
|
75
|
+
expect(client.acquireCalls).toBe(1)
|
|
78
76
|
})
|
|
79
77
|
|
|
80
78
|
it('is idempotent', async () => {
|
|
81
|
-
const client =
|
|
82
|
-
listener =
|
|
79
|
+
const { listener: l, client } = createListener()
|
|
80
|
+
listener = l
|
|
83
81
|
|
|
84
82
|
await listener.start()
|
|
85
83
|
await listener.start()
|
|
86
84
|
|
|
87
|
-
expect(
|
|
85
|
+
expect(client.acquireCalls).toBe(1)
|
|
88
86
|
})
|
|
89
87
|
})
|
|
90
88
|
|
|
91
89
|
describe('connected event', () => {
|
|
92
|
-
it('emits connected
|
|
93
|
-
const client =
|
|
94
|
-
listener =
|
|
90
|
+
it('emits connected after acquiring an already-active session', async () => {
|
|
91
|
+
const { listener: l, client } = createListener()
|
|
92
|
+
listener = l
|
|
93
|
+
client.connected = true
|
|
94
|
+
|
|
95
|
+
const events: Array<{ userId: string }> = []
|
|
96
|
+
listener.on('connected', (info) => events.push(info))
|
|
97
|
+
|
|
98
|
+
await listener.start()
|
|
99
|
+
|
|
100
|
+
expect(events.length).toBe(1)
|
|
101
|
+
expect(events[0].userId).toBe('user1')
|
|
102
|
+
})
|
|
95
103
|
|
|
96
|
-
|
|
97
|
-
listener
|
|
104
|
+
it('emits connected from session-event when client connects after listener starts', async () => {
|
|
105
|
+
const { listener: l, client } = createListener()
|
|
106
|
+
listener = l
|
|
107
|
+
|
|
108
|
+
const events: Array<{ userId: string }> = []
|
|
109
|
+
listener.on('connected', (info) => events.push(info))
|
|
98
110
|
|
|
99
111
|
await listener.start()
|
|
112
|
+
client.emitSessionEvent({ type: 'connected', userId: 'user1' })
|
|
100
113
|
|
|
101
|
-
expect(
|
|
102
|
-
expect(
|
|
114
|
+
expect(events.length).toBe(1)
|
|
115
|
+
expect(events[0].userId).toBe('user1')
|
|
103
116
|
})
|
|
104
117
|
})
|
|
105
118
|
|
|
106
119
|
describe('message events', () => {
|
|
107
120
|
it('emits message on MSG push with parsed fields', async () => {
|
|
108
|
-
const client =
|
|
109
|
-
listener =
|
|
121
|
+
const { listener: l, client } = createListener()
|
|
122
|
+
listener = l
|
|
110
123
|
|
|
111
124
|
const messages: KakaoTalkPushMessageEvent[] = []
|
|
112
125
|
listener.on('message', (event) => messages.push(event))
|
|
113
126
|
|
|
114
127
|
await listener.start()
|
|
115
|
-
|
|
128
|
+
client.emitPush('MSG', {
|
|
116
129
|
chatId: { high: 0, low: 100 },
|
|
117
130
|
chatLog: {
|
|
118
131
|
logId: { high: 0, low: 200 },
|
|
@@ -136,14 +149,14 @@ describe('KakaoTalkListener', () => {
|
|
|
136
149
|
|
|
137
150
|
describe('member events', () => {
|
|
138
151
|
it('emits member_joined on NEWMEM push', async () => {
|
|
139
|
-
const client =
|
|
140
|
-
listener =
|
|
152
|
+
const { listener: l, client } = createListener()
|
|
153
|
+
listener = l
|
|
141
154
|
|
|
142
155
|
const joined: KakaoTalkPushMemberEvent[] = []
|
|
143
156
|
listener.on('member_joined', (event) => joined.push(event))
|
|
144
157
|
|
|
145
158
|
await listener.start()
|
|
146
|
-
|
|
159
|
+
client.emitPush('NEWMEM', {
|
|
147
160
|
chatId: { high: 0, low: 100 },
|
|
148
161
|
chatLog: { authorId: 42 },
|
|
149
162
|
})
|
|
@@ -155,14 +168,14 @@ describe('KakaoTalkListener', () => {
|
|
|
155
168
|
})
|
|
156
169
|
|
|
157
170
|
it('emits member_left on DELMEM push', async () => {
|
|
158
|
-
const client =
|
|
159
|
-
listener =
|
|
171
|
+
const { listener: l, client } = createListener()
|
|
172
|
+
listener = l
|
|
160
173
|
|
|
161
174
|
const left: KakaoTalkPushMemberEvent[] = []
|
|
162
175
|
listener.on('member_left', (event) => left.push(event))
|
|
163
176
|
|
|
164
177
|
await listener.start()
|
|
165
|
-
|
|
178
|
+
client.emitPush('DELMEM', {
|
|
166
179
|
chatId: { high: 0, low: 100 },
|
|
167
180
|
chatLog: { authorId: 42 },
|
|
168
181
|
})
|
|
@@ -176,14 +189,14 @@ describe('KakaoTalkListener', () => {
|
|
|
176
189
|
|
|
177
190
|
describe('read events', () => {
|
|
178
191
|
it('emits read on DECUNREAD push with watermark', async () => {
|
|
179
|
-
const client =
|
|
180
|
-
listener =
|
|
192
|
+
const { listener: l, client } = createListener()
|
|
193
|
+
listener = l
|
|
181
194
|
|
|
182
195
|
const reads: KakaoTalkPushReadEvent[] = []
|
|
183
196
|
listener.on('read', (event) => reads.push(event))
|
|
184
197
|
|
|
185
198
|
await listener.start()
|
|
186
|
-
|
|
199
|
+
client.emitPush('DECUNREAD', {
|
|
187
200
|
chatId: { high: 0, low: 100 },
|
|
188
201
|
userId: 42,
|
|
189
202
|
watermark: { high: 0, low: 999 },
|
|
@@ -199,22 +212,22 @@ describe('KakaoTalkListener', () => {
|
|
|
199
212
|
|
|
200
213
|
describe('kakaotalk_event catch-all', () => {
|
|
201
214
|
it('emits kakaotalk_event for every push event', async () => {
|
|
202
|
-
const client =
|
|
203
|
-
listener =
|
|
215
|
+
const { listener: l, client } = createListener()
|
|
216
|
+
listener = l
|
|
204
217
|
|
|
205
218
|
const events: KakaoTalkPushGenericEvent[] = []
|
|
206
219
|
listener.on('kakaotalk_event', (event) => events.push(event))
|
|
207
220
|
|
|
208
221
|
await listener.start()
|
|
209
|
-
|
|
222
|
+
client.emitPush('MSG', {
|
|
210
223
|
chatId: { high: 0, low: 1 },
|
|
211
224
|
chatLog: { logId: { high: 0, low: 2 }, authorId: 1, message: 'hi', type: 1, sendAt: 1 },
|
|
212
225
|
})
|
|
213
|
-
|
|
226
|
+
client.emitPush('NEWMEM', {
|
|
214
227
|
chatId: { high: 0, low: 1 },
|
|
215
228
|
chatLog: { authorId: 1 },
|
|
216
229
|
})
|
|
217
|
-
|
|
230
|
+
client.emitPush('CUSTOM_EVENT', { some: 'data' })
|
|
218
231
|
|
|
219
232
|
expect(events.length).toBe(3)
|
|
220
233
|
expect(events[0].type).toBe('MSG')
|
|
@@ -224,112 +237,72 @@ describe('KakaoTalkListener', () => {
|
|
|
224
237
|
})
|
|
225
238
|
|
|
226
239
|
describe('stop', () => {
|
|
227
|
-
it('
|
|
228
|
-
const client =
|
|
229
|
-
listener =
|
|
240
|
+
it('unsubscribes from client push and session events', async () => {
|
|
241
|
+
const { listener: l, client } = createListener()
|
|
242
|
+
listener = l
|
|
230
243
|
|
|
231
244
|
await listener.start()
|
|
245
|
+
expect(client.pushHandlers.size).toBe(1)
|
|
246
|
+
expect(client.sessionHandlers.size).toBe(1)
|
|
232
247
|
|
|
233
248
|
listener.stop()
|
|
234
|
-
mockSessionInstance.simulateClose()
|
|
235
249
|
|
|
236
|
-
|
|
237
|
-
expect(
|
|
250
|
+
expect(client.pushHandlers.size).toBe(0)
|
|
251
|
+
expect(client.sessionHandlers.size).toBe(0)
|
|
238
252
|
})
|
|
239
253
|
})
|
|
240
254
|
|
|
241
|
-
describe('
|
|
242
|
-
it('
|
|
243
|
-
const client =
|
|
244
|
-
listener =
|
|
245
|
-
|
|
246
|
-
const disconnected: boolean[] = []
|
|
247
|
-
listener.on('disconnected', () => disconnected.push(true))
|
|
248
|
-
|
|
249
|
-
await listener.start()
|
|
250
|
-
mockSessionInstance.simulateClose()
|
|
251
|
-
|
|
252
|
-
expect(disconnected.length).toBe(1)
|
|
253
|
-
|
|
254
|
-
await new Promise((r) => setTimeout(r, 1500))
|
|
255
|
-
expect(mockLogin.mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
256
|
-
})
|
|
257
|
-
|
|
258
|
-
it('emits error and reconnects on login failure', async () => {
|
|
259
|
-
let callCount = 0
|
|
260
|
-
mockLogin.mockImplementation(() => {
|
|
261
|
-
callCount++
|
|
262
|
-
if (callCount === 1) return Promise.reject(new Error('network_error'))
|
|
263
|
-
return Promise.resolve({})
|
|
264
|
-
})
|
|
265
|
-
|
|
266
|
-
const client = createMockClient()
|
|
267
|
-
listener = new KakaoTalkListener(client)
|
|
268
|
-
|
|
269
|
-
const errors: Error[] = []
|
|
270
|
-
listener.on('error', (err) => errors.push(err))
|
|
271
|
-
|
|
272
|
-
await listener.start()
|
|
273
|
-
|
|
274
|
-
await new Promise((r) => setTimeout(r, 1500))
|
|
275
|
-
|
|
276
|
-
expect(errors.length).toBe(1)
|
|
277
|
-
expect(errors[0].message).toBe('network_error')
|
|
278
|
-
expect(mockLogin.mock.calls.length).toBeGreaterThanOrEqual(2)
|
|
279
|
-
})
|
|
280
|
-
})
|
|
255
|
+
describe('disconnected event', () => {
|
|
256
|
+
it('emits disconnected when the client session drops', async () => {
|
|
257
|
+
const { listener: l, client } = createListener()
|
|
258
|
+
listener = l
|
|
281
259
|
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
const client = createMockClient()
|
|
285
|
-
listener = new KakaoTalkListener(client)
|
|
260
|
+
const disconnects: number[] = []
|
|
261
|
+
listener.on('disconnected', () => disconnects.push(1))
|
|
286
262
|
|
|
287
263
|
await listener.start()
|
|
288
|
-
|
|
264
|
+
client.emitSessionEvent({ type: 'disconnected' })
|
|
289
265
|
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
expect((listener as any).reconnectAttempts).toBe(0)
|
|
266
|
+
expect(disconnects.length).toBe(1)
|
|
293
267
|
})
|
|
294
268
|
})
|
|
295
269
|
|
|
296
270
|
describe('KICKOUT', () => {
|
|
297
|
-
it('emits error and stops
|
|
298
|
-
const client =
|
|
299
|
-
listener =
|
|
271
|
+
it('emits error and stops the listener when the client reports a kicked session', async () => {
|
|
272
|
+
const { listener: l, client } = createListener()
|
|
273
|
+
listener = l
|
|
300
274
|
|
|
301
275
|
const errors: Error[] = []
|
|
302
276
|
listener.on('error', (err) => errors.push(err))
|
|
303
277
|
|
|
304
278
|
await listener.start()
|
|
305
|
-
|
|
279
|
+
client.emitSessionEvent({ type: 'kicked', reason: 'Session kicked — another device logged in' })
|
|
306
280
|
|
|
307
281
|
expect(errors.length).toBe(1)
|
|
308
282
|
expect(errors[0].message).toContain('kicked')
|
|
309
|
-
expect((listener as
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
expect(mockLogin).toHaveBeenCalledTimes(1)
|
|
283
|
+
expect((listener as unknown as { running: boolean }).running).toBe(false)
|
|
284
|
+
expect(client.pushHandlers.size).toBe(0)
|
|
285
|
+
expect(client.sessionHandlers.size).toBe(0)
|
|
313
286
|
})
|
|
314
287
|
})
|
|
315
288
|
|
|
316
289
|
describe('on/off/once', () => {
|
|
317
290
|
it('off removes listener', async () => {
|
|
318
|
-
const client =
|
|
319
|
-
listener =
|
|
291
|
+
const { listener: l, client } = createListener()
|
|
292
|
+
listener = l
|
|
320
293
|
|
|
321
294
|
const messages: KakaoTalkPushMessageEvent[] = []
|
|
322
295
|
const handler = (event: KakaoTalkPushMessageEvent) => messages.push(event)
|
|
323
296
|
listener.on('message', handler)
|
|
324
297
|
|
|
325
298
|
await listener.start()
|
|
326
|
-
|
|
299
|
+
client.emitPush('MSG', {
|
|
327
300
|
chatId: { high: 0, low: 1 },
|
|
328
301
|
chatLog: { logId: { high: 0, low: 1 }, authorId: 1, message: 'first', type: 1, sendAt: 1 },
|
|
329
302
|
})
|
|
330
303
|
|
|
331
304
|
listener.off('message', handler)
|
|
332
|
-
|
|
305
|
+
client.emitPush('MSG', {
|
|
333
306
|
chatId: { high: 0, low: 1 },
|
|
334
307
|
chatLog: { logId: { high: 0, low: 2 }, authorId: 1, message: 'second', type: 1, sendAt: 2 },
|
|
335
308
|
})
|
|
@@ -339,18 +312,18 @@ describe('KakaoTalkListener', () => {
|
|
|
339
312
|
})
|
|
340
313
|
|
|
341
314
|
it('once fires only once', async () => {
|
|
342
|
-
const client =
|
|
343
|
-
listener =
|
|
315
|
+
const { listener: l, client } = createListener()
|
|
316
|
+
listener = l
|
|
344
317
|
|
|
345
318
|
const messages: KakaoTalkPushMessageEvent[] = []
|
|
346
319
|
listener.once('message', (event) => messages.push(event))
|
|
347
320
|
|
|
348
321
|
await listener.start()
|
|
349
|
-
|
|
322
|
+
client.emitPush('MSG', {
|
|
350
323
|
chatId: { high: 0, low: 1 },
|
|
351
324
|
chatLog: { logId: { high: 0, low: 1 }, authorId: 1, message: 'first', type: 1, sendAt: 1 },
|
|
352
325
|
})
|
|
353
|
-
|
|
326
|
+
client.emitPush('MSG', {
|
|
354
327
|
chatId: { high: 0, low: 1 },
|
|
355
328
|
chatLog: { logId: { high: 0, low: 2 }, authorId: 1, message: 'second', type: 1, sendAt: 2 },
|
|
356
329
|
})
|
|
@@ -360,17 +333,50 @@ describe('KakaoTalkListener', () => {
|
|
|
360
333
|
})
|
|
361
334
|
})
|
|
362
335
|
|
|
363
|
-
describe('
|
|
364
|
-
it('
|
|
365
|
-
const client =
|
|
366
|
-
|
|
336
|
+
describe('error during start', () => {
|
|
337
|
+
it('emits error and tears down subscriptions when acquireSession fails', async () => {
|
|
338
|
+
const client = new FakeClient()
|
|
339
|
+
client.acquireImpl = async () => {
|
|
340
|
+
throw new Error('login_failed')
|
|
341
|
+
}
|
|
342
|
+
const l = new KakaoTalkListener(client as unknown as KakaoTalkClient)
|
|
343
|
+
listener = l
|
|
344
|
+
|
|
345
|
+
const errors: Error[] = []
|
|
346
|
+
listener.on('error', (err) => errors.push(err))
|
|
367
347
|
|
|
368
348
|
await listener.start()
|
|
369
|
-
;(listener as any).reconnectAttempts = 5
|
|
370
|
-
listener.stop()
|
|
371
349
|
|
|
350
|
+
expect(errors.length).toBe(1)
|
|
351
|
+
expect(errors[0].message).toBe('login_failed')
|
|
352
|
+
expect(client.pushHandlers.size).toBe(0)
|
|
353
|
+
expect(client.sessionHandlers.size).toBe(0)
|
|
354
|
+
})
|
|
355
|
+
|
|
356
|
+
it('can be restarted after a failed start()', async () => {
|
|
357
|
+
// given — a client whose first acquire fails but later succeeds
|
|
358
|
+
const client = new FakeClient()
|
|
359
|
+
let attempts = 0
|
|
360
|
+
client.acquireImpl = async () => {
|
|
361
|
+
attempts++
|
|
362
|
+
if (attempts === 1) throw new Error('login_failed')
|
|
363
|
+
}
|
|
364
|
+
const l = new KakaoTalkListener(client as unknown as KakaoTalkClient)
|
|
365
|
+
listener = l
|
|
366
|
+
|
|
367
|
+
const errors: Error[] = []
|
|
368
|
+
listener.on('error', (err) => errors.push(err))
|
|
369
|
+
|
|
370
|
+
// when — first start() fails, then we try again
|
|
372
371
|
await listener.start()
|
|
373
|
-
expect(
|
|
372
|
+
expect(errors.length).toBe(1)
|
|
373
|
+
|
|
374
|
+
await listener.start()
|
|
375
|
+
|
|
376
|
+
// then — second start succeeds (subscriptions re-attached, acquire was retried)
|
|
377
|
+
expect(attempts).toBe(2)
|
|
378
|
+
expect(client.pushHandlers.size).toBe(1)
|
|
379
|
+
expect(client.sessionHandlers.size).toBe(1)
|
|
374
380
|
})
|
|
375
381
|
})
|
|
376
382
|
})
|