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.
Files changed (32) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/kakaotalk/client.d.ts +22 -0
  4. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  5. package/dist/src/platforms/kakaotalk/client.js +93 -7
  6. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  7. package/dist/src/platforms/kakaotalk/listener.d.ts +4 -7
  8. package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
  9. package/dist/src/platforms/kakaotalk/listener.js +43 -72
  10. package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
  11. package/package.json +1 -1
  12. package/skills/agent-channeltalk/SKILL.md +1 -1
  13. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  14. package/skills/agent-discord/SKILL.md +1 -1
  15. package/skills/agent-discordbot/SKILL.md +1 -1
  16. package/skills/agent-instagram/SKILL.md +1 -1
  17. package/skills/agent-kakaotalk/SKILL.md +1 -1
  18. package/skills/agent-line/SKILL.md +1 -1
  19. package/skills/agent-slack/SKILL.md +1 -1
  20. package/skills/agent-slackbot/SKILL.md +1 -1
  21. package/skills/agent-teams/SKILL.md +1 -1
  22. package/skills/agent-telegram/SKILL.md +1 -1
  23. package/skills/agent-telegrambot/SKILL.md +1 -1
  24. package/skills/agent-webex/SKILL.md +1 -1
  25. package/skills/agent-wechatbot/SKILL.md +1 -1
  26. package/skills/agent-whatsapp/SKILL.md +1 -1
  27. package/skills/agent-whatsappbot/SKILL.md +1 -1
  28. package/src/platforms/kakaotalk/client-listener-integration.test.ts +411 -0
  29. package/src/platforms/kakaotalk/client.test.ts +3 -0
  30. package/src/platforms/kakaotalk/client.ts +108 -9
  31. package/src/platforms/kakaotalk/listener.test.ts +155 -149
  32. 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
- this.initPromise = null
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, mock, it } from 'bun:test'
1
+ import { afterEach, describe, expect, it } from 'bun:test'
2
2
 
3
- import { KakaoTalkListener } from '@/platforms/kakaotalk/listener'
4
- import type { LocoPacket } from '@/platforms/kakaotalk/protocol/types'
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 '@/platforms/kakaotalk/types'
11
-
12
- const mockLogin = mock(() => Promise.resolve({}))
13
- const mockSessionClose = mock(() => {})
14
-
15
- let mockSessionInstance: MockLocoSession
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
- class MockLocoSession {
18
- pushHandler: ((packet: LocoPacket) => void) | null = null
19
- closeHandler: (() => void) | null = null
20
- login = mockLogin
21
- close = mockSessionClose
27
+ isConnected(): boolean {
28
+ return this.connected
29
+ }
22
30
 
23
- constructor() {
24
- // oxlint-disable-next-line typescript-eslint/no-this-alias
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: (packet: LocoPacket) => void): void {
29
- this.pushHandler = handler
35
+ onPush(handler: KakaoPushHandler): () => void {
36
+ this.pushHandlers.add(handler)
37
+ return () => this.pushHandlers.delete(handler)
30
38
  }
31
39
 
32
- onClose(handler: () => void): void {
33
- this.closeHandler = handler
40
+ onSessionEvent(handler: KakaoSessionEventHandler): () => void {
41
+ this.sessionHandlers.add(handler)
42
+ return () => this.sessionHandlers.delete(handler)
34
43
  }
35
44
 
36
- simulatePush(method: string, body: Record<string, unknown>): void {
37
- this.pushHandler?.({ packetId: 0, statusCode: 0, method, bodyType: 0, body })
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
- simulateClose(): void {
41
- this.closeHandler?.()
50
+ emitSessionEvent(event: KakaoSessionEvent): void {
51
+ for (const handler of this.sessionHandlers) handler(event)
42
52
  }
43
53
  }
44
54
 
45
- mock.module('./protocol/session', () => ({ LocoSession: MockLocoSession }))
46
-
47
- function createMockClient(overrides: Record<string, unknown> = {}) {
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('calls login on LocoSession', async () => {
71
- const client = createMockClient()
72
- listener = new KakaoTalkListener(client)
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(mockLogin).toHaveBeenCalledTimes(1)
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 = createMockClient()
82
- listener = new KakaoTalkListener(client)
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(mockLogin).toHaveBeenCalledTimes(1)
85
+ expect(client.acquireCalls).toBe(1)
88
86
  })
89
87
  })
90
88
 
91
89
  describe('connected event', () => {
92
- it('emits connected with userId after successful login', async () => {
93
- const client = createMockClient()
94
- listener = new KakaoTalkListener(client)
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
- const connected: Array<{ userId: string }> = []
97
- listener.on('connected', (info) => connected.push(info))
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(connected.length).toBe(1)
102
- expect(connected[0].userId).toBe('user1')
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 = createMockClient()
109
- listener = new KakaoTalkListener(client)
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
- mockSessionInstance.simulatePush('MSG', {
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 = createMockClient()
140
- listener = new KakaoTalkListener(client)
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
- mockSessionInstance.simulatePush('NEWMEM', {
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 = createMockClient()
159
- listener = new KakaoTalkListener(client)
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
- mockSessionInstance.simulatePush('DELMEM', {
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 = createMockClient()
180
- listener = new KakaoTalkListener(client)
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
- mockSessionInstance.simulatePush('DECUNREAD', {
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 = createMockClient()
203
- listener = new KakaoTalkListener(client)
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
- mockSessionInstance.simulatePush('MSG', {
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
- mockSessionInstance.simulatePush('NEWMEM', {
226
+ client.emitPush('NEWMEM', {
214
227
  chatId: { high: 0, low: 1 },
215
228
  chatLog: { authorId: 1 },
216
229
  })
217
- mockSessionInstance.simulatePush('CUSTOM_EVENT', { some: 'data' })
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('closes session and prevents reconnection', async () => {
228
- const client = createMockClient()
229
- listener = new KakaoTalkListener(client)
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
- await new Promise((r) => setTimeout(r, 50))
237
- expect(mockLogin).toHaveBeenCalledTimes(1)
250
+ expect(client.pushHandlers.size).toBe(0)
251
+ expect(client.sessionHandlers.size).toBe(0)
238
252
  })
239
253
  })
240
254
 
241
- describe('reconnection', () => {
242
- it('reconnects on session close when still running', async () => {
243
- const client = createMockClient()
244
- listener = new KakaoTalkListener(client)
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
- describe('CHANGESVR', () => {
283
- it('resets reconnect attempts to 0 on CHANGESVR push', async () => {
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
- ;(listener as any).reconnectAttempts = 5
264
+ client.emitSessionEvent({ type: 'disconnected' })
289
265
 
290
- mockSessionInstance.simulatePush('CHANGESVR', {})
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 without reconnecting', async () => {
298
- const client = createMockClient()
299
- listener = new KakaoTalkListener(client)
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
- mockSessionInstance.simulatePush('KICKOUT', {})
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 any).running).toBe(false)
310
-
311
- await new Promise((r) => setTimeout(r, 50))
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 = createMockClient()
319
- listener = new KakaoTalkListener(client)
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
- mockSessionInstance.simulatePush('MSG', {
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
- mockSessionInstance.simulatePush('MSG', {
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 = createMockClient()
343
- listener = new KakaoTalkListener(client)
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
- mockSessionInstance.simulatePush('MSG', {
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
- mockSessionInstance.simulatePush('MSG', {
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('start after stop', () => {
364
- it('resets reconnect attempts on fresh start', async () => {
365
- const client = createMockClient()
366
- listener = new KakaoTalkListener(client)
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((listener as any).reconnectAttempts).toBe(0)
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
  })