agent-messenger 2.12.0 → 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 (121) hide show
  1. package/.claude-plugin/README.md +11 -1
  2. package/.claude-plugin/marketplace.json +14 -1
  3. package/.claude-plugin/plugin.json +4 -2
  4. package/CONTRIBUTING.md +12 -0
  5. package/README.md +30 -4
  6. package/dist/package.json +10 -2
  7. package/dist/src/cli.d.ts.map +1 -1
  8. package/dist/src/cli.js +3 -0
  9. package/dist/src/cli.js.map +1 -1
  10. package/dist/src/platforms/kakaotalk/client.d.ts +22 -0
  11. package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
  12. package/dist/src/platforms/kakaotalk/client.js +93 -7
  13. package/dist/src/platforms/kakaotalk/client.js.map +1 -1
  14. package/dist/src/platforms/kakaotalk/listener.d.ts +4 -7
  15. package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
  16. package/dist/src/platforms/kakaotalk/listener.js +43 -72
  17. package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
  18. package/dist/src/platforms/telegrambot/cli.d.ts +5 -0
  19. package/dist/src/platforms/telegrambot/cli.d.ts.map +1 -0
  20. package/dist/src/platforms/telegrambot/cli.js +29 -0
  21. package/dist/src/platforms/telegrambot/cli.js.map +1 -0
  22. package/dist/src/platforms/telegrambot/client.d.ts +85 -0
  23. package/dist/src/platforms/telegrambot/client.d.ts.map +1 -0
  24. package/dist/src/platforms/telegrambot/client.js +282 -0
  25. package/dist/src/platforms/telegrambot/client.js.map +1 -0
  26. package/dist/src/platforms/telegrambot/commands/auth.d.ts +31 -0
  27. package/dist/src/platforms/telegrambot/commands/auth.d.ts.map +1 -0
  28. package/dist/src/platforms/telegrambot/commands/auth.js +173 -0
  29. package/dist/src/platforms/telegrambot/commands/auth.js.map +1 -0
  30. package/dist/src/platforms/telegrambot/commands/chat.d.ts +25 -0
  31. package/dist/src/platforms/telegrambot/commands/chat.d.ts.map +1 -0
  32. package/dist/src/platforms/telegrambot/commands/chat.js +69 -0
  33. package/dist/src/platforms/telegrambot/commands/chat.js.map +1 -0
  34. package/dist/src/platforms/telegrambot/commands/index.d.ts +6 -0
  35. package/dist/src/platforms/telegrambot/commands/index.d.ts.map +1 -0
  36. package/dist/src/platforms/telegrambot/commands/index.js +6 -0
  37. package/dist/src/platforms/telegrambot/commands/index.js.map +1 -0
  38. package/dist/src/platforms/telegrambot/commands/message.d.ts +39 -0
  39. package/dist/src/platforms/telegrambot/commands/message.d.ts.map +1 -0
  40. package/dist/src/platforms/telegrambot/commands/message.js +145 -0
  41. package/dist/src/platforms/telegrambot/commands/message.js.map +1 -0
  42. package/dist/src/platforms/telegrambot/commands/reaction.d.ts +16 -0
  43. package/dist/src/platforms/telegrambot/commands/reaction.d.ts.map +1 -0
  44. package/dist/src/platforms/telegrambot/commands/reaction.js +49 -0
  45. package/dist/src/platforms/telegrambot/commands/reaction.js.map +1 -0
  46. package/dist/src/platforms/telegrambot/commands/shared.d.ts +12 -0
  47. package/dist/src/platforms/telegrambot/commands/shared.d.ts.map +1 -0
  48. package/dist/src/platforms/telegrambot/commands/shared.js +21 -0
  49. package/dist/src/platforms/telegrambot/commands/shared.js.map +1 -0
  50. package/dist/src/platforms/telegrambot/commands/whoami.d.ts +17 -0
  51. package/dist/src/platforms/telegrambot/commands/whoami.d.ts.map +1 -0
  52. package/dist/src/platforms/telegrambot/commands/whoami.js +30 -0
  53. package/dist/src/platforms/telegrambot/commands/whoami.js.map +1 -0
  54. package/dist/src/platforms/telegrambot/credential-manager.d.ts +17 -0
  55. package/dist/src/platforms/telegrambot/credential-manager.d.ts.map +1 -0
  56. package/dist/src/platforms/telegrambot/credential-manager.js +113 -0
  57. package/dist/src/platforms/telegrambot/credential-manager.js.map +1 -0
  58. package/dist/src/platforms/telegrambot/index.d.ts +7 -0
  59. package/dist/src/platforms/telegrambot/index.d.ts.map +1 -0
  60. package/dist/src/platforms/telegrambot/index.js +5 -0
  61. package/dist/src/platforms/telegrambot/index.js.map +1 -0
  62. package/dist/src/platforms/telegrambot/listener.d.ts +30 -0
  63. package/dist/src/platforms/telegrambot/listener.d.ts.map +1 -0
  64. package/dist/src/platforms/telegrambot/listener.js +186 -0
  65. package/dist/src/platforms/telegrambot/listener.js.map +1 -0
  66. package/dist/src/platforms/telegrambot/types.d.ts +256 -0
  67. package/dist/src/platforms/telegrambot/types.d.ts.map +1 -0
  68. package/dist/src/platforms/telegrambot/types.js +96 -0
  69. package/dist/src/platforms/telegrambot/types.js.map +1 -0
  70. package/docs/content/docs/cli/meta.json +1 -0
  71. package/docs/content/docs/cli/telegrambot.mdx +149 -0
  72. package/docs/content/docs/index.mdx +10 -9
  73. package/docs/content/docs/quick-start.mdx +2 -0
  74. package/docs/content/docs/sdk/meta.json +1 -0
  75. package/docs/content/docs/sdk/telegrambot.mdx +216 -0
  76. package/e2e/config.ts +24 -0
  77. package/e2e/helpers.ts +1 -0
  78. package/e2e/telegrambot.e2e.test.ts +185 -0
  79. package/examples/telegrambot-listen.ts +54 -0
  80. package/package.json +10 -2
  81. package/scripts/postbuild.ts +1 -0
  82. package/skills/agent-channeltalk/SKILL.md +1 -1
  83. package/skills/agent-channeltalkbot/SKILL.md +1 -1
  84. package/skills/agent-discord/SKILL.md +1 -1
  85. package/skills/agent-discordbot/SKILL.md +1 -1
  86. package/skills/agent-instagram/SKILL.md +1 -1
  87. package/skills/agent-kakaotalk/SKILL.md +12 -5
  88. package/skills/agent-line/SKILL.md +1 -1
  89. package/skills/agent-slack/SKILL.md +1 -1
  90. package/skills/agent-slackbot/SKILL.md +1 -1
  91. package/skills/agent-teams/SKILL.md +1 -1
  92. package/skills/agent-telegram/SKILL.md +1 -1
  93. package/skills/agent-telegrambot/SKILL.md +357 -0
  94. package/skills/agent-webex/SKILL.md +1 -1
  95. package/skills/agent-wechatbot/SKILL.md +1 -1
  96. package/skills/agent-whatsapp/SKILL.md +1 -1
  97. package/skills/agent-whatsappbot/SKILL.md +1 -1
  98. package/src/cli.ts +4 -0
  99. package/src/platforms/kakaotalk/client-listener-integration.test.ts +411 -0
  100. package/src/platforms/kakaotalk/client.test.ts +3 -0
  101. package/src/platforms/kakaotalk/client.ts +108 -9
  102. package/src/platforms/kakaotalk/listener.test.ts +155 -149
  103. package/src/platforms/kakaotalk/listener.ts +46 -80
  104. package/src/platforms/telegrambot/cli.ts +34 -0
  105. package/src/platforms/telegrambot/client.test.ts +454 -0
  106. package/src/platforms/telegrambot/client.ts +404 -0
  107. package/src/platforms/telegrambot/commands/auth.test.ts +244 -0
  108. package/src/platforms/telegrambot/commands/auth.ts +220 -0
  109. package/src/platforms/telegrambot/commands/chat.ts +96 -0
  110. package/src/platforms/telegrambot/commands/index.ts +5 -0
  111. package/src/platforms/telegrambot/commands/message.ts +235 -0
  112. package/src/platforms/telegrambot/commands/reaction.ts +70 -0
  113. package/src/platforms/telegrambot/commands/shared.ts +32 -0
  114. package/src/platforms/telegrambot/commands/whoami.ts +45 -0
  115. package/src/platforms/telegrambot/credential-manager.test.ts +196 -0
  116. package/src/platforms/telegrambot/credential-manager.ts +141 -0
  117. package/src/platforms/telegrambot/index.ts +44 -0
  118. package/src/platforms/telegrambot/listener.test.ts +398 -0
  119. package/src/platforms/telegrambot/listener.ts +198 -0
  120. package/src/platforms/telegrambot/types.test.ts +128 -0
  121. package/src/platforms/telegrambot/types.ts +282 -0
@@ -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
  })
@@ -1,7 +1,6 @@
1
1
  import { EventEmitter } from 'events'
2
2
 
3
- import type { KakaoTalkClient } from './client'
4
- import { LocoSession } from './protocol/session'
3
+ import type { KakaoSessionEvent, KakaoTalkClient } from './client'
5
4
  import type { LocoPacket } from './protocol/types'
6
5
  import type {
7
6
  KakaoTalkListenerEventMap,
@@ -11,9 +10,6 @@ import type {
11
10
  KakaoTalkPushReadEvent,
12
11
  } from './types'
13
12
 
14
- const RECONNECT_BASE_DELAY = 1_000
15
- const RECONNECT_MAX_DELAY = 30_000
16
-
17
13
  type EventKey = keyof KakaoTalkListenerEventMap
18
14
 
19
15
  function longToString(v: unknown): string {
@@ -27,11 +23,9 @@ function longToString(v: unknown): string {
27
23
  export class KakaoTalkListener {
28
24
  private client: KakaoTalkClient
29
25
  private running = false
30
- private session: LocoSession | null = null
31
26
  private emitter = new EventEmitter()
32
- private reconnectTimer: ReturnType<typeof setTimeout> | null = null
33
- private reconnectAttempts = 0
34
- private userId: string | null = null
27
+ private unsubscribePush: (() => void) | null = null
28
+ private unsubscribeSession: (() => void) | null = null
35
29
 
36
30
  constructor(client: KakaoTalkClient) {
37
31
  this.client = client
@@ -40,17 +34,33 @@ export class KakaoTalkListener {
40
34
  async start(): Promise<void> {
41
35
  if (this.running) return
42
36
  this.running = true
43
- this.reconnectAttempts = 0
44
- await this.connect()
37
+
38
+ this.unsubscribePush = this.client.onPush((packet) => this.handlePush(packet))
39
+ this.unsubscribeSession = this.client.onSessionEvent((event) => this.handleSessionEvent(event))
40
+
41
+ const alreadyConnected = this.client.isConnected()
42
+
43
+ try {
44
+ await this.client.acquireSession()
45
+ if (!this.running) return
46
+ if (alreadyConnected) {
47
+ const { userId } = this.client.getCredentials()
48
+ this.emitter.emit('connected', { userId })
49
+ }
50
+ } catch (error) {
51
+ this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
52
+ this.running = false
53
+ this.teardown()
54
+ }
45
55
  }
46
56
 
47
57
  stop(): void {
48
- this.running = false
49
- this.clearTimers()
50
- if (this.session) {
51
- this.session.close()
52
- this.session = null
58
+ if (!this.running) {
59
+ this.teardown()
60
+ return
53
61
  }
62
+ this.running = false
63
+ this.teardown()
54
64
  }
55
65
 
56
66
  on<K extends EventKey>(event: K, listener: (...args: KakaoTalkListenerEventMap[K]) => void): this {
@@ -68,41 +78,28 @@ export class KakaoTalkListener {
68
78
  return this
69
79
  }
70
80
 
71
- private async connect(): Promise<void> {
72
- if (!this.running) return
73
-
74
- try {
75
- const { oauthToken, userId, deviceUuid, deviceType } = this.client.getCredentials()
76
- if (!this.running) return
77
-
78
- this.userId = userId
79
- const session = new LocoSession()
80
-
81
- session.onPush((packet) => this.handlePush(packet))
82
- session.onClose(() => {
83
- if (this.session !== session) return
84
- this.session = null
85
- if (this.running) {
86
- this.emitter.emit('disconnected')
87
- this.scheduleReconnect()
88
- }
89
- })
90
-
91
- await session.login(oauthToken, userId, deviceUuid, undefined, deviceType)
81
+ private teardown(): void {
82
+ this.unsubscribePush?.()
83
+ this.unsubscribePush = null
84
+ this.unsubscribeSession?.()
85
+ this.unsubscribeSession = null
86
+ }
92
87
 
93
- if (!this.running) {
94
- session.close()
95
- return
96
- }
88
+ private handleSessionEvent(event: KakaoSessionEvent): void {
89
+ if (!this.running) return
97
90
 
98
- this.reconnectAttempts = 0
99
- this.session = session
100
- this.emitter.emit('connected', { userId })
101
- } catch (error) {
102
- this.emitter.emit('error', error instanceof Error ? error : new Error(String(error)))
103
- if (this.running) {
104
- this.scheduleReconnect()
105
- }
91
+ switch (event.type) {
92
+ case 'connected':
93
+ this.emitter.emit('connected', { userId: event.userId })
94
+ break
95
+ case 'disconnected':
96
+ this.emitter.emit('disconnected')
97
+ break
98
+ case 'kicked':
99
+ this.emitter.emit('error', new Error(event.reason))
100
+ this.running = false
101
+ this.teardown()
102
+ break
106
103
  }
107
104
  }
108
105
 
@@ -162,23 +159,6 @@ export class KakaoTalkListener {
162
159
  break
163
160
  }
164
161
 
165
- case 'CHANGESVR': {
166
- this.reconnectAttempts = 0
167
- const prev = this.session
168
- this.session = null
169
- prev?.close()
170
- this.connect()
171
- break
172
- }
173
-
174
- case 'KICKOUT': {
175
- this.emitter.emit('error', new Error('Session kicked — another device logged in'))
176
- this.running = false
177
- this.session?.close()
178
- this.session = null
179
- break
180
- }
181
-
182
162
  default: {
183
163
  const event: KakaoTalkPushGenericEvent = { type: method, ...body }
184
164
  this.emitter.emit('kakaotalk_event', event)
@@ -186,18 +166,4 @@ export class KakaoTalkListener {
186
166
  }
187
167
  }
188
168
  }
189
-
190
- private scheduleReconnect(): void {
191
- this.clearTimers()
192
- const delay = Math.min(RECONNECT_BASE_DELAY * 2 ** this.reconnectAttempts, RECONNECT_MAX_DELAY)
193
- this.reconnectAttempts++
194
- this.reconnectTimer = setTimeout(() => this.connect(), delay)
195
- }
196
-
197
- private clearTimers(): void {
198
- if (this.reconnectTimer) {
199
- clearTimeout(this.reconnectTimer)
200
- this.reconnectTimer = null
201
- }
202
- }
203
169
  }