agent-messenger 2.12.1 → 2.13.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (87) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/dist/package.json +1 -1
  3. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +18 -0
  4. package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -0
  5. package/dist/src/platforms/kakaotalk/chat-classifier.js +29 -0
  6. package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -0
  7. package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
  8. package/dist/src/platforms/kakaotalk/cli.js +2 -1
  9. package/dist/src/platforms/kakaotalk/cli.js.map +1 -1
  10. package/dist/src/platforms/kakaotalk/client.d.ts +35 -1
  11. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  12. package/dist/src/platforms/kakaotalk/client.js +318 -15
  13. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  14. package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
  15. package/dist/src/platforms/kakaotalk/commands/chat.js +2 -1
  16. package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
  17. package/dist/src/platforms/kakaotalk/commands/index.d.ts +1 -0
  18. package/dist/src/platforms/kakaotalk/commands/index.d.ts.map +1 -1
  19. package/dist/src/platforms/kakaotalk/commands/index.js +1 -0
  20. package/dist/src/platforms/kakaotalk/commands/index.js.map +1 -1
  21. package/dist/src/platforms/kakaotalk/commands/member.d.ts +3 -0
  22. package/dist/src/platforms/kakaotalk/commands/member.d.ts.map +1 -0
  23. package/dist/src/platforms/kakaotalk/commands/member.js +22 -0
  24. package/dist/src/platforms/kakaotalk/commands/member.js.map +1 -0
  25. package/dist/src/platforms/kakaotalk/index.d.ts +4 -2
  26. package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
  27. package/dist/src/platforms/kakaotalk/index.js +2 -1
  28. package/dist/src/platforms/kakaotalk/index.js.map +1 -1
  29. package/dist/src/platforms/kakaotalk/listener.d.ts +4 -7
  30. package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
  31. package/dist/src/platforms/kakaotalk/listener.js +48 -74
  32. package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
  33. package/dist/src/platforms/kakaotalk/protocol/session.d.ts +28 -0
  34. package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
  35. package/dist/src/platforms/kakaotalk/protocol/session.js +44 -0
  36. package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
  37. package/dist/src/platforms/kakaotalk/types.d.ts +37 -0
  38. package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
  39. package/dist/src/platforms/kakaotalk/types.js +17 -0
  40. package/dist/src/platforms/kakaotalk/types.js.map +1 -1
  41. package/dist/src/platforms/slackbot/client.d.ts +5 -0
  42. package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
  43. package/dist/src/platforms/slackbot/client.js +5 -0
  44. package/dist/src/platforms/slackbot/client.js.map +1 -1
  45. package/dist/src/tui/adapters/kakaotalk-adapter.js +3 -3
  46. package/dist/src/tui/adapters/kakaotalk-adapter.js.map +1 -1
  47. package/docs/content/docs/cli/kakaotalk.mdx +26 -1
  48. package/docs/content/docs/sdk/kakaotalk.mdx +45 -13
  49. package/docs/content/docs/sdk/slackbot.mdx +11 -0
  50. package/package.json +1 -1
  51. package/scripts/kakao-loco-capture.ts +466 -0
  52. package/skills/agent-channeltalk/SKILL.md +1 -1
  53. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  54. package/skills/agent-discord/SKILL.md +1 -1
  55. package/skills/agent-discordbot/SKILL.md +1 -1
  56. package/skills/agent-instagram/SKILL.md +1 -1
  57. package/skills/agent-kakaotalk/SKILL.md +30 -3
  58. package/skills/agent-kakaotalk/references/common-patterns.md +49 -1
  59. package/skills/agent-line/SKILL.md +1 -1
  60. package/skills/agent-slack/SKILL.md +1 -1
  61. package/skills/agent-slackbot/SKILL.md +1 -2
  62. package/skills/agent-teams/SKILL.md +1 -1
  63. package/skills/agent-telegram/SKILL.md +1 -1
  64. package/skills/agent-telegrambot/SKILL.md +1 -1
  65. package/skills/agent-webex/SKILL.md +1 -1
  66. package/skills/agent-wechatbot/SKILL.md +1 -1
  67. package/skills/agent-whatsapp/SKILL.md +1 -1
  68. package/skills/agent-whatsappbot/SKILL.md +1 -1
  69. package/src/platforms/kakaotalk/chat-classifier.test.ts +33 -0
  70. package/src/platforms/kakaotalk/chat-classifier.ts +31 -0
  71. package/src/platforms/kakaotalk/cli.ts +2 -1
  72. package/src/platforms/kakaotalk/client-listener-integration.test.ts +411 -0
  73. package/src/platforms/kakaotalk/client.test.ts +785 -1
  74. package/src/platforms/kakaotalk/client.ts +369 -18
  75. package/src/platforms/kakaotalk/commands/chat.ts +3 -1
  76. package/src/platforms/kakaotalk/commands/index.ts +1 -0
  77. package/src/platforms/kakaotalk/commands/member.test.ts +102 -0
  78. package/src/platforms/kakaotalk/commands/member.ts +32 -0
  79. package/src/platforms/kakaotalk/index.test.ts +5 -0
  80. package/src/platforms/kakaotalk/index.ts +4 -0
  81. package/src/platforms/kakaotalk/listener.test.ts +184 -149
  82. package/src/platforms/kakaotalk/listener.ts +51 -82
  83. package/src/platforms/kakaotalk/protocol/session.ts +44 -0
  84. package/src/platforms/kakaotalk/types.ts +39 -0
  85. package/src/platforms/slackbot/client.test.ts +67 -0
  86. package/src/platforms/slackbot/client.ts +17 -1
  87. package/src/tui/adapters/kakaotalk-adapter.ts +3 -3
@@ -0,0 +1,32 @@
1
+ import { Command } from 'commander'
2
+
3
+ import { handleError } from '@/shared/utils/error-handler'
4
+ import { formatOutput } from '@/shared/utils/output'
5
+
6
+ import { withKakaoClient } from './shared'
7
+
8
+ async function listAction(
9
+ chatId: string,
10
+ options: {
11
+ account?: string
12
+ pretty?: boolean
13
+ },
14
+ ): Promise<void> {
15
+ try {
16
+ const members = await withKakaoClient(options, (client) => client.getMembers(chatId))
17
+ console.log(formatOutput(members, options.pretty))
18
+ } catch (error) {
19
+ handleError(error as Error)
20
+ }
21
+ }
22
+
23
+ export const memberCommand = new Command('member')
24
+ .description('KakaoTalk member commands')
25
+ .addCommand(
26
+ new Command('list')
27
+ .description('List members of a chat room')
28
+ .argument('<chat-id>', 'Chat room ID')
29
+ .option('--account <id>', 'Use a specific KakaoTalk account')
30
+ .option('--pretty', 'Pretty print JSON output')
31
+ .action(listAction),
32
+ )
@@ -1,6 +1,7 @@
1
1
  import { expect, it } from 'bun:test'
2
2
 
3
3
  import {
4
+ classifyKakaoChat,
4
5
  CredentialManager,
5
6
  KakaoAccountCredentialsSchema,
6
7
  KakaoCredentialManager,
@@ -72,3 +73,7 @@ it('KakaoTalkPushReadEventSchema is exported from barrel', () => {
72
73
  it('KakaoProfileSchema is exported from barrel', () => {
73
74
  expect(typeof KakaoProfileSchema.parse).toBe('function')
74
75
  })
76
+
77
+ it('classifyKakaoChat is exported from barrel', () => {
78
+ expect(typeof classifyKakaoChat).toBe('function')
79
+ })
@@ -1,4 +1,6 @@
1
1
  export { KakaoTalkClient, KakaoTalkError } from './client'
2
+ export { classifyKakaoChat } from './chat-classifier'
3
+ export type { KakaoChatKind } from './chat-classifier'
2
4
  export { KakaoCredentialManager, CredentialManager } from './credential-manager'
3
5
  export { KakaoTalkListener } from './listener'
4
6
  export type { PendingLoginState } from './credential-manager'
@@ -8,6 +10,7 @@ export type {
8
10
  KakaoChat,
9
11
  KakaoConfig,
10
12
  KakaoDeviceType,
13
+ KakaoMember,
11
14
  KakaoMessage,
12
15
  KakaoProfile,
13
16
  KakaoSendResult,
@@ -22,6 +25,7 @@ export {
22
25
  KakaoAccountCredentialsSchema,
23
26
  KakaoChatSchema,
24
27
  KakaoConfigSchema,
28
+ KakaoMemberSchema,
25
29
  KakaoMessageSchema,
26
30
  KakaoProfileSchema,
27
31
  KakaoSendResultSchema,
@@ -1,59 +1,62 @@
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
+ lookupAuthorName: (chatId: string, authorId: number) => string | null = () => null
20
+
21
+ async acquireSession(): Promise<unknown> {
22
+ this.acquireCalls++
23
+ await this.acquireImpl()
24
+ this.connected = true
25
+ return {}
26
+ }
16
27
 
17
- class MockLocoSession {
18
- pushHandler: ((packet: LocoPacket) => void) | null = null
19
- closeHandler: (() => void) | null = null
20
- login = mockLogin
21
- close = mockSessionClose
28
+ isConnected(): boolean {
29
+ return this.connected
30
+ }
22
31
 
23
- constructor() {
24
- // oxlint-disable-next-line typescript-eslint/no-this-alias
25
- mockSessionInstance = this
32
+ getCredentials(): { oauthToken: string; userId: string; deviceUuid: string; deviceType: 'tablet' } {
33
+ return { oauthToken: 'token', userId: 'user1', deviceUuid: 'device1', deviceType: 'tablet' }
26
34
  }
27
35
 
28
- onPush(handler: (packet: LocoPacket) => void): void {
29
- this.pushHandler = handler
36
+ onPush(handler: KakaoPushHandler): () => void {
37
+ this.pushHandlers.add(handler)
38
+ return () => this.pushHandlers.delete(handler)
30
39
  }
31
40
 
32
- onClose(handler: () => void): void {
33
- this.closeHandler = handler
41
+ onSessionEvent(handler: KakaoSessionEventHandler): () => void {
42
+ this.sessionHandlers.add(handler)
43
+ return () => this.sessionHandlers.delete(handler)
34
44
  }
35
45
 
36
- simulatePush(method: string, body: Record<string, unknown>): void {
37
- this.pushHandler?.({ packetId: 0, statusCode: 0, method, bodyType: 0, body })
46
+ emitPush(method: string, body: Record<string, unknown>): void {
47
+ const packet: LocoPacket = { packetId: 0, statusCode: 0, method, bodyType: 0, body }
48
+ for (const handler of this.pushHandlers) handler(packet)
38
49
  }
39
50
 
40
- simulateClose(): void {
41
- this.closeHandler?.()
51
+ emitSessionEvent(event: KakaoSessionEvent): void {
52
+ for (const handler of this.sessionHandlers) handler(event)
42
53
  }
43
54
  }
44
55
 
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
56
+ function createListener(overrides: Partial<FakeClient> = {}): { listener: KakaoTalkListener; client: FakeClient } {
57
+ const client = Object.assign(new FakeClient(), overrides)
58
+ const listener = new KakaoTalkListener(client as unknown as KakaoTalkClient)
59
+ return { listener, client }
57
60
  }
58
61
 
59
62
  describe('KakaoTalkListener', () => {
@@ -61,58 +64,69 @@ describe('KakaoTalkListener', () => {
61
64
 
62
65
  afterEach(() => {
63
66
  listener?.stop()
64
- mockLogin.mockReset()
65
- mockLogin.mockResolvedValue({})
66
- mockSessionClose.mockReset()
67
67
  })
68
68
 
69
69
  describe('start', () => {
70
- it('calls login on LocoSession', async () => {
71
- const client = createMockClient()
72
- listener = new KakaoTalkListener(client)
70
+ it('acquires the shared session from the client', async () => {
71
+ const { listener: l, client } = createListener()
72
+ listener = l
73
73
 
74
74
  await listener.start()
75
75
 
76
- expect(mockLogin).toHaveBeenCalledTimes(1)
77
- expect(mockLogin).toHaveBeenCalledWith('token', 'user1', 'device1', undefined, 'tablet')
76
+ expect(client.acquireCalls).toBe(1)
78
77
  })
79
78
 
80
79
  it('is idempotent', async () => {
81
- const client = createMockClient()
82
- listener = new KakaoTalkListener(client)
80
+ const { listener: l, client } = createListener()
81
+ listener = l
83
82
 
84
83
  await listener.start()
85
84
  await listener.start()
86
85
 
87
- expect(mockLogin).toHaveBeenCalledTimes(1)
86
+ expect(client.acquireCalls).toBe(1)
88
87
  })
89
88
  })
90
89
 
91
90
  describe('connected event', () => {
92
- it('emits connected with userId after successful login', async () => {
93
- const client = createMockClient()
94
- listener = new KakaoTalkListener(client)
91
+ it('emits connected after acquiring an already-active session', async () => {
92
+ const { listener: l, client } = createListener()
93
+ listener = l
94
+ client.connected = true
95
+
96
+ const events: Array<{ userId: string }> = []
97
+ listener.on('connected', (info) => events.push(info))
98
+
99
+ await listener.start()
100
+
101
+ expect(events.length).toBe(1)
102
+ expect(events[0].userId).toBe('user1')
103
+ })
104
+
105
+ it('emits connected from session-event when client connects after listener starts', async () => {
106
+ const { listener: l, client } = createListener()
107
+ listener = l
95
108
 
96
- const connected: Array<{ userId: string }> = []
97
- listener.on('connected', (info) => connected.push(info))
109
+ const events: Array<{ userId: string }> = []
110
+ listener.on('connected', (info) => events.push(info))
98
111
 
99
112
  await listener.start()
113
+ client.emitSessionEvent({ type: 'connected', userId: 'user1' })
100
114
 
101
- expect(connected.length).toBe(1)
102
- expect(connected[0].userId).toBe('user1')
115
+ expect(events.length).toBe(1)
116
+ expect(events[0].userId).toBe('user1')
103
117
  })
104
118
  })
105
119
 
106
120
  describe('message events', () => {
107
121
  it('emits message on MSG push with parsed fields', async () => {
108
- const client = createMockClient()
109
- listener = new KakaoTalkListener(client)
122
+ const { listener: l, client } = createListener()
123
+ listener = l
110
124
 
111
125
  const messages: KakaoTalkPushMessageEvent[] = []
112
126
  listener.on('message', (event) => messages.push(event))
113
127
 
114
128
  await listener.start()
115
- mockSessionInstance.simulatePush('MSG', {
129
+ client.emitPush('MSG', {
116
130
  chatId: { high: 0, low: 100 },
117
131
  chatLog: {
118
132
  logId: { high: 0, low: 200 },
@@ -128,22 +142,50 @@ describe('KakaoTalkListener', () => {
128
142
  expect(messages[0].chat_id).toBe('100')
129
143
  expect(messages[0].log_id).toBe('200')
130
144
  expect(messages[0].author_id).toBe(42)
145
+ expect(messages[0].author_name).toBeNull()
131
146
  expect(messages[0].message).toBe('hello world')
132
147
  expect(messages[0].message_type).toBe(1)
133
148
  expect(messages[0].sent_at).toBe(1700000000)
134
149
  })
150
+
151
+ it('resolves author_name from client.lookupAuthorName when available', async () => {
152
+ const { listener: l, client } = createListener()
153
+ listener = l
154
+
155
+ client.lookupAuthorName = (chatId: string, authorId: number) => {
156
+ if (chatId === '100' && authorId === 42) return 'Alice'
157
+ return null
158
+ }
159
+
160
+ const messages: KakaoTalkPushMessageEvent[] = []
161
+ listener.on('message', (event) => messages.push(event))
162
+
163
+ await listener.start()
164
+ client.emitPush('MSG', {
165
+ chatId: { high: 0, low: 100 },
166
+ chatLog: {
167
+ logId: { high: 0, low: 200 },
168
+ authorId: 42,
169
+ message: 'hello',
170
+ type: 1,
171
+ sendAt: 1700000000,
172
+ },
173
+ })
174
+
175
+ expect(messages[0].author_name).toBe('Alice')
176
+ })
135
177
  })
136
178
 
137
179
  describe('member events', () => {
138
180
  it('emits member_joined on NEWMEM push', async () => {
139
- const client = createMockClient()
140
- listener = new KakaoTalkListener(client)
181
+ const { listener: l, client } = createListener()
182
+ listener = l
141
183
 
142
184
  const joined: KakaoTalkPushMemberEvent[] = []
143
185
  listener.on('member_joined', (event) => joined.push(event))
144
186
 
145
187
  await listener.start()
146
- mockSessionInstance.simulatePush('NEWMEM', {
188
+ client.emitPush('NEWMEM', {
147
189
  chatId: { high: 0, low: 100 },
148
190
  chatLog: { authorId: 42 },
149
191
  })
@@ -155,14 +197,14 @@ describe('KakaoTalkListener', () => {
155
197
  })
156
198
 
157
199
  it('emits member_left on DELMEM push', async () => {
158
- const client = createMockClient()
159
- listener = new KakaoTalkListener(client)
200
+ const { listener: l, client } = createListener()
201
+ listener = l
160
202
 
161
203
  const left: KakaoTalkPushMemberEvent[] = []
162
204
  listener.on('member_left', (event) => left.push(event))
163
205
 
164
206
  await listener.start()
165
- mockSessionInstance.simulatePush('DELMEM', {
207
+ client.emitPush('DELMEM', {
166
208
  chatId: { high: 0, low: 100 },
167
209
  chatLog: { authorId: 42 },
168
210
  })
@@ -176,14 +218,14 @@ describe('KakaoTalkListener', () => {
176
218
 
177
219
  describe('read events', () => {
178
220
  it('emits read on DECUNREAD push with watermark', async () => {
179
- const client = createMockClient()
180
- listener = new KakaoTalkListener(client)
221
+ const { listener: l, client } = createListener()
222
+ listener = l
181
223
 
182
224
  const reads: KakaoTalkPushReadEvent[] = []
183
225
  listener.on('read', (event) => reads.push(event))
184
226
 
185
227
  await listener.start()
186
- mockSessionInstance.simulatePush('DECUNREAD', {
228
+ client.emitPush('DECUNREAD', {
187
229
  chatId: { high: 0, low: 100 },
188
230
  userId: 42,
189
231
  watermark: { high: 0, low: 999 },
@@ -199,22 +241,22 @@ describe('KakaoTalkListener', () => {
199
241
 
200
242
  describe('kakaotalk_event catch-all', () => {
201
243
  it('emits kakaotalk_event for every push event', async () => {
202
- const client = createMockClient()
203
- listener = new KakaoTalkListener(client)
244
+ const { listener: l, client } = createListener()
245
+ listener = l
204
246
 
205
247
  const events: KakaoTalkPushGenericEvent[] = []
206
248
  listener.on('kakaotalk_event', (event) => events.push(event))
207
249
 
208
250
  await listener.start()
209
- mockSessionInstance.simulatePush('MSG', {
251
+ client.emitPush('MSG', {
210
252
  chatId: { high: 0, low: 1 },
211
253
  chatLog: { logId: { high: 0, low: 2 }, authorId: 1, message: 'hi', type: 1, sendAt: 1 },
212
254
  })
213
- mockSessionInstance.simulatePush('NEWMEM', {
255
+ client.emitPush('NEWMEM', {
214
256
  chatId: { high: 0, low: 1 },
215
257
  chatLog: { authorId: 1 },
216
258
  })
217
- mockSessionInstance.simulatePush('CUSTOM_EVENT', { some: 'data' })
259
+ client.emitPush('CUSTOM_EVENT', { some: 'data' })
218
260
 
219
261
  expect(events.length).toBe(3)
220
262
  expect(events[0].type).toBe('MSG')
@@ -224,112 +266,72 @@ describe('KakaoTalkListener', () => {
224
266
  })
225
267
 
226
268
  describe('stop', () => {
227
- it('closes session and prevents reconnection', async () => {
228
- const client = createMockClient()
229
- listener = new KakaoTalkListener(client)
269
+ it('unsubscribes from client push and session events', async () => {
270
+ const { listener: l, client } = createListener()
271
+ listener = l
230
272
 
231
273
  await listener.start()
274
+ expect(client.pushHandlers.size).toBe(1)
275
+ expect(client.sessionHandlers.size).toBe(1)
232
276
 
233
277
  listener.stop()
234
- mockSessionInstance.simulateClose()
235
278
 
236
- await new Promise((r) => setTimeout(r, 50))
237
- expect(mockLogin).toHaveBeenCalledTimes(1)
279
+ expect(client.pushHandlers.size).toBe(0)
280
+ expect(client.sessionHandlers.size).toBe(0)
238
281
  })
239
282
  })
240
283
 
241
- describe('reconnection', () => {
242
- it('reconnects on session close when still running', async () => {
243
- const client = createMockClient()
244
- listener = new KakaoTalkListener(client)
284
+ describe('disconnected event', () => {
285
+ it('emits disconnected when the client session drops', async () => {
286
+ const { listener: l, client } = createListener()
287
+ listener = l
245
288
 
246
- const disconnected: boolean[] = []
247
- listener.on('disconnected', () => disconnected.push(true))
289
+ const disconnects: number[] = []
290
+ listener.on('disconnected', () => disconnects.push(1))
248
291
 
249
292
  await listener.start()
250
- mockSessionInstance.simulateClose()
251
-
252
- expect(disconnected.length).toBe(1)
293
+ client.emitSessionEvent({ type: 'disconnected' })
253
294
 
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
- })
281
-
282
- describe('CHANGESVR', () => {
283
- it('resets reconnect attempts to 0 on CHANGESVR push', async () => {
284
- const client = createMockClient()
285
- listener = new KakaoTalkListener(client)
286
-
287
- await listener.start()
288
- ;(listener as any).reconnectAttempts = 5
289
-
290
- mockSessionInstance.simulatePush('CHANGESVR', {})
291
-
292
- expect((listener as any).reconnectAttempts).toBe(0)
295
+ expect(disconnects.length).toBe(1)
293
296
  })
294
297
  })
295
298
 
296
299
  describe('KICKOUT', () => {
297
- it('emits error and stops without reconnecting', async () => {
298
- const client = createMockClient()
299
- listener = new KakaoTalkListener(client)
300
+ it('emits error and stops the listener when the client reports a kicked session', async () => {
301
+ const { listener: l, client } = createListener()
302
+ listener = l
300
303
 
301
304
  const errors: Error[] = []
302
305
  listener.on('error', (err) => errors.push(err))
303
306
 
304
307
  await listener.start()
305
- mockSessionInstance.simulatePush('KICKOUT', {})
308
+ client.emitSessionEvent({ type: 'kicked', reason: 'Session kicked — another device logged in' })
306
309
 
307
310
  expect(errors.length).toBe(1)
308
311
  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)
312
+ expect((listener as unknown as { running: boolean }).running).toBe(false)
313
+ expect(client.pushHandlers.size).toBe(0)
314
+ expect(client.sessionHandlers.size).toBe(0)
313
315
  })
314
316
  })
315
317
 
316
318
  describe('on/off/once', () => {
317
319
  it('off removes listener', async () => {
318
- const client = createMockClient()
319
- listener = new KakaoTalkListener(client)
320
+ const { listener: l, client } = createListener()
321
+ listener = l
320
322
 
321
323
  const messages: KakaoTalkPushMessageEvent[] = []
322
324
  const handler = (event: KakaoTalkPushMessageEvent) => messages.push(event)
323
325
  listener.on('message', handler)
324
326
 
325
327
  await listener.start()
326
- mockSessionInstance.simulatePush('MSG', {
328
+ client.emitPush('MSG', {
327
329
  chatId: { high: 0, low: 1 },
328
330
  chatLog: { logId: { high: 0, low: 1 }, authorId: 1, message: 'first', type: 1, sendAt: 1 },
329
331
  })
330
332
 
331
333
  listener.off('message', handler)
332
- mockSessionInstance.simulatePush('MSG', {
334
+ client.emitPush('MSG', {
333
335
  chatId: { high: 0, low: 1 },
334
336
  chatLog: { logId: { high: 0, low: 2 }, authorId: 1, message: 'second', type: 1, sendAt: 2 },
335
337
  })
@@ -339,18 +341,18 @@ describe('KakaoTalkListener', () => {
339
341
  })
340
342
 
341
343
  it('once fires only once', async () => {
342
- const client = createMockClient()
343
- listener = new KakaoTalkListener(client)
344
+ const { listener: l, client } = createListener()
345
+ listener = l
344
346
 
345
347
  const messages: KakaoTalkPushMessageEvent[] = []
346
348
  listener.once('message', (event) => messages.push(event))
347
349
 
348
350
  await listener.start()
349
- mockSessionInstance.simulatePush('MSG', {
351
+ client.emitPush('MSG', {
350
352
  chatId: { high: 0, low: 1 },
351
353
  chatLog: { logId: { high: 0, low: 1 }, authorId: 1, message: 'first', type: 1, sendAt: 1 },
352
354
  })
353
- mockSessionInstance.simulatePush('MSG', {
355
+ client.emitPush('MSG', {
354
356
  chatId: { high: 0, low: 1 },
355
357
  chatLog: { logId: { high: 0, low: 2 }, authorId: 1, message: 'second', type: 1, sendAt: 2 },
356
358
  })
@@ -360,17 +362,50 @@ describe('KakaoTalkListener', () => {
360
362
  })
361
363
  })
362
364
 
363
- describe('start after stop', () => {
364
- it('resets reconnect attempts on fresh start', async () => {
365
- const client = createMockClient()
366
- listener = new KakaoTalkListener(client)
365
+ describe('error during start', () => {
366
+ it('emits error and tears down subscriptions when acquireSession fails', async () => {
367
+ const client = new FakeClient()
368
+ client.acquireImpl = async () => {
369
+ throw new Error('login_failed')
370
+ }
371
+ const l = new KakaoTalkListener(client as unknown as KakaoTalkClient)
372
+ listener = l
373
+
374
+ const errors: Error[] = []
375
+ listener.on('error', (err) => errors.push(err))
367
376
 
368
377
  await listener.start()
369
- ;(listener as any).reconnectAttempts = 5
370
- listener.stop()
371
378
 
379
+ expect(errors.length).toBe(1)
380
+ expect(errors[0].message).toBe('login_failed')
381
+ expect(client.pushHandlers.size).toBe(0)
382
+ expect(client.sessionHandlers.size).toBe(0)
383
+ })
384
+
385
+ it('can be restarted after a failed start()', async () => {
386
+ // given — a client whose first acquire fails but later succeeds
387
+ const client = new FakeClient()
388
+ let attempts = 0
389
+ client.acquireImpl = async () => {
390
+ attempts++
391
+ if (attempts === 1) throw new Error('login_failed')
392
+ }
393
+ const l = new KakaoTalkListener(client as unknown as KakaoTalkClient)
394
+ listener = l
395
+
396
+ const errors: Error[] = []
397
+ listener.on('error', (err) => errors.push(err))
398
+
399
+ // when — first start() fails, then we try again
372
400
  await listener.start()
373
- expect((listener as any).reconnectAttempts).toBe(0)
401
+ expect(errors.length).toBe(1)
402
+
403
+ await listener.start()
404
+
405
+ // then — second start succeeds (subscriptions re-attached, acquire was retried)
406
+ expect(attempts).toBe(2)
407
+ expect(client.pushHandlers.size).toBe(1)
408
+ expect(client.sessionHandlers.size).toBe(1)
374
409
  })
375
410
  })
376
411
  })