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
@@ -0,0 +1,411 @@
1
+ import { afterEach, beforeEach, describe, expect, mock, it } from 'bun:test'
2
+
3
+ import { KakaoTalkClient } from './client'
4
+ import { KakaoTalkListener } from './listener'
5
+ import type { LocoPacket } from './protocol/types'
6
+ import type { KakaoTalkPushMessageEvent } from './types'
7
+
8
+ const sessions: MockLocoSession[] = []
9
+ const loginCalls: Array<{
10
+ oauthToken: string
11
+ userId: string
12
+ deviceUuid: string
13
+ syncState: unknown
14
+ deviceType: string
15
+ }> = []
16
+
17
+ class MockLocoSession {
18
+ pushHandler: ((packet: LocoPacket) => void) | null = null
19
+ closeHandler: (() => void) | null = null
20
+ closed = false
21
+ loginResult: Record<string, unknown> = {
22
+ chatDatas: [],
23
+ lastTokenId: { low: 0, high: 0 },
24
+ lastChatId: { low: 0, high: 0 },
25
+ eof: true,
26
+ }
27
+
28
+ loginImpl: (
29
+ oauthToken: string,
30
+ userId: string,
31
+ deviceUuid: string,
32
+ syncState: unknown,
33
+ deviceType: string,
34
+ ) => Promise<unknown> = async (oauthToken, userId, deviceUuid, syncState, deviceType) => {
35
+ loginCalls.push({ oauthToken, userId, deviceUuid, syncState, deviceType })
36
+ return this.loginResult
37
+ }
38
+
39
+ sendMessageImpl: (chatId: unknown, text: string) => Promise<unknown> = async () => ({
40
+ statusCode: 0,
41
+ body: { logId: { low: 1, high: 0 }, sendAt: 1 },
42
+ })
43
+
44
+ getChatLogsImpl: () => Promise<unknown> = async () => ({
45
+ body: { status: 0, chatLogs: [], eof: true },
46
+ })
47
+
48
+ constructor() {
49
+ sessions.push(this)
50
+ }
51
+
52
+ login(
53
+ oauthToken: string,
54
+ userId: string,
55
+ deviceUuid: string,
56
+ syncState: unknown,
57
+ deviceType: string,
58
+ ): Promise<unknown> {
59
+ return this.loginImpl(oauthToken, userId, deviceUuid, syncState, deviceType)
60
+ }
61
+
62
+ sendMessage(chatId: unknown, text: string): Promise<unknown> {
63
+ return this.sendMessageImpl(chatId, text)
64
+ }
65
+
66
+ getChatLogs(): Promise<unknown> {
67
+ return this.getChatLogsImpl()
68
+ }
69
+
70
+ getChatList(): Promise<unknown> {
71
+ return Promise.resolve({ body: { chatDatas: [], lastTokenId: { low: 0, high: 0 }, eof: true } })
72
+ }
73
+
74
+ getChatInfo(): Promise<unknown> {
75
+ return Promise.resolve({ body: { l: { low: 0, high: 0 } } })
76
+ }
77
+
78
+ syncMessages(): Promise<unknown> {
79
+ return Promise.resolve({ body: { chatLogs: [], isOK: true } })
80
+ }
81
+
82
+ onPush(handler: (packet: LocoPacket) => void): void {
83
+ this.pushHandler = handler
84
+ }
85
+
86
+ onClose(handler: () => void): void {
87
+ this.closeHandler = handler
88
+ }
89
+
90
+ close(): void {
91
+ if (this.closed) return
92
+ this.closed = true
93
+ this.closeHandler?.()
94
+ }
95
+
96
+ simulatePush(method: string, body: Record<string, unknown> = {}): void {
97
+ this.pushHandler?.({ packetId: 0, statusCode: 0, method, bodyType: 0, body })
98
+ }
99
+
100
+ simulateRemoteClose(): void {
101
+ if (this.closed) return
102
+ this.closed = true
103
+ this.closeHandler?.()
104
+ }
105
+ }
106
+
107
+ mock.module('./protocol/session', () => ({ LocoSession: MockLocoSession }))
108
+
109
+ const CREDS = {
110
+ oauthToken: 'tok',
111
+ userId: 'user1',
112
+ deviceUuid: 'device-uuid-1',
113
+ deviceType: 'tablet' as const,
114
+ }
115
+
116
+ function currentSession(): MockLocoSession {
117
+ return sessions[sessions.length - 1]!
118
+ }
119
+
120
+ describe('KakaoTalkClient + KakaoTalkListener integration (shared LOCO session)', () => {
121
+ beforeEach(() => {
122
+ sessions.length = 0
123
+ loginCalls.length = 0
124
+ })
125
+
126
+ afterEach(() => {
127
+ sessions.length = 0
128
+ loginCalls.length = 0
129
+ })
130
+
131
+ it('opens exactly ONE LocoSession when a client and listener are used together', async () => {
132
+ // given — a client logged in and a listener attached
133
+ const client = await new KakaoTalkClient().login(CREDS)
134
+ const listener = new KakaoTalkListener(client)
135
+
136
+ // when — listener starts AND client makes API calls
137
+ await listener.start()
138
+ await client.sendMessage('100', 'hi')
139
+ await client.getChats()
140
+
141
+ // then — only one LocoSession was constructed; only one LOGINLIST sent
142
+ expect(sessions.length).toBe(1)
143
+ expect(loginCalls.length).toBe(1)
144
+ expect(loginCalls[0].deviceUuid).toBe(CREDS.deviceUuid)
145
+
146
+ listener.stop()
147
+ client.close()
148
+ })
149
+
150
+ it('never sends a duplicate LOGINLIST with the same duuid while a session is alive', async () => {
151
+ // given — a strict guard: any second login while the first session is still open is a self-KICKOUT.
152
+ const client = await new KakaoTalkClient().login(CREDS)
153
+ const listener = new KakaoTalkListener(client)
154
+ await listener.start()
155
+
156
+ // when — heavy interleaving of outbound calls and inbound pushes
157
+ for (let i = 0; i < 10; i++) {
158
+ await client.sendMessage(String(100 + i), `msg-${i}`)
159
+ currentSession().simulatePush('MSG', {
160
+ chatId: { low: 100 + i, high: 0 },
161
+ chatLog: { logId: { low: i, high: 0 }, authorId: 1, message: `pushed-${i}`, type: 1, sendAt: i },
162
+ })
163
+ }
164
+
165
+ // then — still exactly one LOGINLIST, never a second one against the live session
166
+ const liveSessions = sessions.filter((s) => !s.closed)
167
+ expect(liveSessions.length).toBe(1)
168
+ expect(loginCalls.length).toBe(1)
169
+
170
+ listener.stop()
171
+ client.close()
172
+ })
173
+
174
+ it('listener still receives push events when only the listener is used (no API calls)', async () => {
175
+ const client = await new KakaoTalkClient().login(CREDS)
176
+ const listener = new KakaoTalkListener(client)
177
+
178
+ const messages: KakaoTalkPushMessageEvent[] = []
179
+ listener.on('message', (event) => messages.push(event))
180
+
181
+ await listener.start()
182
+ expect(sessions.length).toBe(1)
183
+
184
+ currentSession().simulatePush('MSG', {
185
+ chatId: { low: 100, high: 0 },
186
+ chatLog: {
187
+ logId: { low: 1, high: 0 },
188
+ authorId: 42,
189
+ message: 'hello',
190
+ type: 1,
191
+ sendAt: 1700000000,
192
+ },
193
+ })
194
+
195
+ expect(messages.length).toBe(1)
196
+ expect(messages[0].chat_id).toBe('100')
197
+ expect(messages[0].message).toBe('hello')
198
+
199
+ listener.stop()
200
+ client.close()
201
+ })
202
+
203
+ it('client-only usage still works (no listener)', async () => {
204
+ const client = await new KakaoTalkClient().login(CREDS)
205
+ const result = await client.sendMessage('100', 'hi')
206
+
207
+ expect(result.success).toBe(true)
208
+ expect(sessions.length).toBe(1)
209
+
210
+ client.close()
211
+ })
212
+
213
+ it('real KICKOUT propagates to listener and closes the session', async () => {
214
+ // given — client + listener sharing a session
215
+ const client = await new KakaoTalkClient().login(CREDS)
216
+ const listener = new KakaoTalkListener(client)
217
+
218
+ const errors: Error[] = []
219
+ listener.on('error', (err) => errors.push(err))
220
+
221
+ await listener.start()
222
+ expect(sessions.length).toBe(1)
223
+ const sharedSession = currentSession()
224
+
225
+ // when — server pushes a KICKOUT (a different real device logged in)
226
+ sharedSession.simulatePush('KICKOUT', {})
227
+
228
+ // then — listener emits the canonical error and stops itself
229
+ expect(errors.length).toBe(1)
230
+ expect(errors[0].message).toContain('kicked')
231
+ expect((listener as unknown as { running: boolean }).running).toBe(false)
232
+
233
+ listener.stop()
234
+ client.close()
235
+ })
236
+
237
+ it('reconnect after a TCP-level disconnect produces ONE replacement session shared by both halves', async () => {
238
+ // given — listener + client on a shared session
239
+ const client = await new KakaoTalkClient().login(CREDS)
240
+ const listener = new KakaoTalkListener(client)
241
+
242
+ const disconnects: number[] = []
243
+ const connects: Array<{ userId: string }> = []
244
+ listener.on('disconnected', () => disconnects.push(Date.now()))
245
+ listener.on('connected', (info) => connects.push(info))
246
+
247
+ await listener.start()
248
+ expect(sessions.length).toBe(1)
249
+ expect(connects.length).toBe(1)
250
+
251
+ // when — the underlying socket dies (not a KICKOUT)
252
+ currentSession().simulateRemoteClose()
253
+
254
+ // then — listener observed the disconnect
255
+ expect(disconnects.length).toBe(1)
256
+
257
+ // and — the next API call transparently opens exactly ONE replacement session
258
+ await client.sendMessage('100', 'after-reconnect')
259
+
260
+ expect(sessions.length).toBe(2)
261
+ // and — the listener was re-attached to that replacement session (i.e. push fan-out works again)
262
+ const messages: KakaoTalkPushMessageEvent[] = []
263
+ listener.on('message', (event) => messages.push(event))
264
+ currentSession().simulatePush('MSG', {
265
+ chatId: { low: 100, high: 0 },
266
+ chatLog: { logId: { low: 9, high: 0 }, authorId: 1, message: 'after-reconnect-push', type: 1, sendAt: 1 },
267
+ })
268
+ expect(messages.length).toBe(1)
269
+ expect(connects.length).toBe(2)
270
+
271
+ listener.stop()
272
+ client.close()
273
+ })
274
+
275
+ it('CHANGESVR triggers an active session migration', async () => {
276
+ // given — a listener attached to a shared session
277
+ const client = await new KakaoTalkClient().login(CREDS)
278
+ const listener = new KakaoTalkListener(client)
279
+
280
+ const disconnects: number[] = []
281
+ const connects: Array<{ userId: string }> = []
282
+ const generic: Array<{ type: string }> = []
283
+ listener.on('disconnected', () => disconnects.push(1))
284
+ listener.on('connected', (info) => connects.push(info))
285
+ listener.on('kakaotalk_event', (event) => generic.push(event))
286
+
287
+ await listener.start()
288
+ expect(sessions.length).toBe(1)
289
+ expect(connects.length).toBe(1)
290
+
291
+ // when — the server pushes CHANGESVR (asking us to migrate to a new gateway)
292
+ sessions[0]!.simulatePush('CHANGESVR', {})
293
+
294
+ // then — the client actively migrates: old session closed, new one opened
295
+ await new Promise((r) => setTimeout(r, 0))
296
+ expect(sessions.length).toBe(2)
297
+ expect(sessions[0]!.closed).toBe(true)
298
+ expect(disconnects.length).toBe(1)
299
+ expect(connects.length).toBe(2)
300
+
301
+ // and — the listener re-attached to the replacement session for push events
302
+ const messages: KakaoTalkPushMessageEvent[] = []
303
+ listener.on('message', (event) => messages.push(event))
304
+ sessions[1]!.simulatePush('MSG', {
305
+ chatId: { low: 100, high: 0 },
306
+ chatLog: { logId: { low: 7, high: 0 }, authorId: 1, message: 'after-changesvr', type: 1, sendAt: 1 },
307
+ })
308
+ expect(messages.length).toBe(1)
309
+
310
+ // and — CHANGESVR was still surfaced as a generic event for observers that care
311
+ expect(generic.some((e) => e.type === 'CHANGESVR')).toBe(true)
312
+
313
+ listener.stop()
314
+ client.close()
315
+ })
316
+
317
+ it('does not open duplicate LOGINLIST when concurrent calls trigger executeWithReconnect retry', async () => {
318
+ // given — a client+listener with a slow LOGINLIST so concurrent reconnects can collide
319
+ let inflight = 0
320
+ let peakInflight = 0
321
+ const originalLogin = MockLocoSession.prototype.login
322
+ MockLocoSession.prototype.login = async function (oauthToken, userId, deviceUuid, syncState, deviceType) {
323
+ inflight++
324
+ peakInflight = Math.max(peakInflight, inflight)
325
+ try {
326
+ await new Promise((r) => setTimeout(r, 20))
327
+ return await originalLogin.call(this, oauthToken, userId, deviceUuid, syncState, deviceType)
328
+ } finally {
329
+ inflight--
330
+ }
331
+ }
332
+
333
+ try {
334
+ const client = await new KakaoTalkClient().login(CREDS)
335
+ await client.sendMessage('100', 'prime')
336
+ expect(sessions.length).toBe(1)
337
+ expect(loginCalls.length).toBe(1)
338
+
339
+ // Make the live session's sendMessage drop the socket and then throw — modeling a
340
+ // mid-flight TCP reset where the remote-close handler nulls this.state synchronously
341
+ // before the operation rejection bubbles up to executeWithReconnect's catch block.
342
+ // This is the precise window the executeWithReconnect retry path was designed for.
343
+ const dead = sessions[0]!
344
+ dead.sendMessageImpl = async function () {
345
+ dead.simulateRemoteClose()
346
+ throw new Error('socket closed')
347
+ }
348
+
349
+ // when — three concurrent sendMessage calls all hit the dead session and retry
350
+ await Promise.all([
351
+ client.sendMessage('100', 'a'),
352
+ client.sendMessage('100', 'b'),
353
+ client.sendMessage('100', 'c'),
354
+ ])
355
+
356
+ // then — exactly ONE replacement LOGINLIST regardless of retry collisions
357
+ expect(peakInflight).toBe(1)
358
+ expect(loginCalls.length).toBe(2)
359
+ expect(sessions.length).toBe(2)
360
+
361
+ client.close()
362
+ } finally {
363
+ MockLocoSession.prototype.login = originalLogin
364
+ }
365
+ })
366
+
367
+ it('drops pushes from a session that has not been adopted as the active state', async () => {
368
+ // given — a listener subscribed to push events, before any session is opened
369
+ const client = await new KakaoTalkClient().login(CREDS)
370
+ const listener = new KakaoTalkListener(client)
371
+ const messages: KakaoTalkPushMessageEvent[] = []
372
+ listener.on('message', (event) => messages.push(event))
373
+
374
+ // Slow down login so we can fire a push DURING the connect()/login window,
375
+ // when LocoSession has been constructed but this.state is still null.
376
+ let preAdoptionPushFired = false
377
+ const originalLogin = MockLocoSession.prototype.login
378
+ MockLocoSession.prototype.login = async function (oauthToken, userId, deviceUuid, syncState, deviceType) {
379
+ if (!preAdoptionPushFired) {
380
+ preAdoptionPushFired = true
381
+ this.simulatePush('MSG', {
382
+ chatId: { low: 1, high: 0 },
383
+ chatLog: { logId: { low: 1, high: 0 }, authorId: 1, message: 'too-early', type: 1, sendAt: 1 },
384
+ })
385
+ }
386
+ return originalLogin.call(this, oauthToken, userId, deviceUuid, syncState, deviceType)
387
+ }
388
+
389
+ try {
390
+ // when — start the listener, which triggers connect() and a pre-adoption push
391
+ await listener.start()
392
+ expect(preAdoptionPushFired).toBe(true)
393
+
394
+ // then — the pre-adoption push must NOT have leaked to subscribers
395
+ expect(messages.length).toBe(0)
396
+
397
+ // and — once the session is adopted, fresh pushes flow normally
398
+ sessions[0]!.simulatePush('MSG', {
399
+ chatId: { low: 1, high: 0 },
400
+ chatLog: { logId: { low: 2, high: 0 }, authorId: 1, message: 'fresh', type: 1, sendAt: 2 },
401
+ })
402
+ expect(messages.length).toBe(1)
403
+ expect(messages[0].message).toBe('fresh')
404
+
405
+ listener.stop()
406
+ client.close()
407
+ } finally {
408
+ MockLocoSession.prototype.login = originalLogin
409
+ }
410
+ })
411
+ })
@@ -11,6 +11,7 @@ const mockSyncMessages = mock(() => Promise.resolve({}))
11
11
  const mockSendMessage = mock(() => Promise.resolve({}))
12
12
  const mockClose = mock(() => {})
13
13
  const mockOnClose = mock((_handler: () => void) => {})
14
+ const mockOnPush = mock((_handler: (packet: unknown) => void) => {})
14
15
 
15
16
  mock.module('./protocol/session', () => ({
16
17
  LocoSession: class MockLocoSession {
@@ -22,6 +23,7 @@ mock.module('./protocol/session', () => ({
22
23
  sendMessage = mockSendMessage
23
24
  close = mockClose
24
25
  onClose = mockOnClose
26
+ onPush = mockOnPush
25
27
  },
26
28
  }))
27
29
 
@@ -38,6 +40,7 @@ function resetAllMocks() {
38
40
  mockSendMessage.mockReset()
39
41
  mockClose.mockReset()
40
42
  mockOnClose.mockReset()
43
+ mockOnPush.mockReset()
41
44
  }
42
45
 
43
46
  // LOCO protocol uses plain numbers for chat.c, but Long-like objects for logIds/cursors
@@ -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
  }