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.
- package/.claude-plugin/README.md +11 -1
- package/.claude-plugin/marketplace.json +14 -1
- package/.claude-plugin/plugin.json +4 -2
- package/CONTRIBUTING.md +12 -0
- package/README.md +30 -4
- package/dist/package.json +10 -2
- package/dist/src/cli.d.ts.map +1 -1
- package/dist/src/cli.js +3 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +22 -0
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +93 -7
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.d.ts +4 -7
- package/dist/src/platforms/kakaotalk/listener.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/listener.js +43 -72
- package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
- package/dist/src/platforms/telegrambot/cli.d.ts +5 -0
- package/dist/src/platforms/telegrambot/cli.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/cli.js +29 -0
- package/dist/src/platforms/telegrambot/cli.js.map +1 -0
- package/dist/src/platforms/telegrambot/client.d.ts +85 -0
- package/dist/src/platforms/telegrambot/client.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/client.js +282 -0
- package/dist/src/platforms/telegrambot/client.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/auth.d.ts +31 -0
- package/dist/src/platforms/telegrambot/commands/auth.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/auth.js +173 -0
- package/dist/src/platforms/telegrambot/commands/auth.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/chat.d.ts +25 -0
- package/dist/src/platforms/telegrambot/commands/chat.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/chat.js +69 -0
- package/dist/src/platforms/telegrambot/commands/chat.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/index.d.ts +6 -0
- package/dist/src/platforms/telegrambot/commands/index.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/index.js +6 -0
- package/dist/src/platforms/telegrambot/commands/index.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/message.d.ts +39 -0
- package/dist/src/platforms/telegrambot/commands/message.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/message.js +145 -0
- package/dist/src/platforms/telegrambot/commands/message.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/reaction.d.ts +16 -0
- package/dist/src/platforms/telegrambot/commands/reaction.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/reaction.js +49 -0
- package/dist/src/platforms/telegrambot/commands/reaction.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/shared.d.ts +12 -0
- package/dist/src/platforms/telegrambot/commands/shared.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/shared.js +21 -0
- package/dist/src/platforms/telegrambot/commands/shared.js.map +1 -0
- package/dist/src/platforms/telegrambot/commands/whoami.d.ts +17 -0
- package/dist/src/platforms/telegrambot/commands/whoami.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/commands/whoami.js +30 -0
- package/dist/src/platforms/telegrambot/commands/whoami.js.map +1 -0
- package/dist/src/platforms/telegrambot/credential-manager.d.ts +17 -0
- package/dist/src/platforms/telegrambot/credential-manager.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/credential-manager.js +113 -0
- package/dist/src/platforms/telegrambot/credential-manager.js.map +1 -0
- package/dist/src/platforms/telegrambot/index.d.ts +7 -0
- package/dist/src/platforms/telegrambot/index.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/index.js +5 -0
- package/dist/src/platforms/telegrambot/index.js.map +1 -0
- package/dist/src/platforms/telegrambot/listener.d.ts +30 -0
- package/dist/src/platforms/telegrambot/listener.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/listener.js +186 -0
- package/dist/src/platforms/telegrambot/listener.js.map +1 -0
- package/dist/src/platforms/telegrambot/types.d.ts +256 -0
- package/dist/src/platforms/telegrambot/types.d.ts.map +1 -0
- package/dist/src/platforms/telegrambot/types.js +96 -0
- package/dist/src/platforms/telegrambot/types.js.map +1 -0
- package/docs/content/docs/cli/meta.json +1 -0
- package/docs/content/docs/cli/telegrambot.mdx +149 -0
- package/docs/content/docs/index.mdx +10 -9
- package/docs/content/docs/quick-start.mdx +2 -0
- package/docs/content/docs/sdk/meta.json +1 -0
- package/docs/content/docs/sdk/telegrambot.mdx +216 -0
- package/e2e/config.ts +24 -0
- package/e2e/helpers.ts +1 -0
- package/e2e/telegrambot.e2e.test.ts +185 -0
- package/examples/telegrambot-listen.ts +54 -0
- package/package.json +10 -2
- package/scripts/postbuild.ts +1 -0
- package/skills/agent-channeltalk/SKILL.md +1 -1
- package/skills/agent-channeltalkbot/SKILL.md +1 -1
- package/skills/agent-discord/SKILL.md +1 -1
- package/skills/agent-discordbot/SKILL.md +1 -1
- package/skills/agent-instagram/SKILL.md +1 -1
- package/skills/agent-kakaotalk/SKILL.md +12 -5
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -1
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +357 -0
- package/skills/agent-webex/SKILL.md +1 -1
- package/skills/agent-wechatbot/SKILL.md +1 -1
- package/skills/agent-whatsapp/SKILL.md +1 -1
- package/skills/agent-whatsappbot/SKILL.md +1 -1
- package/src/cli.ts +4 -0
- package/src/platforms/kakaotalk/client-listener-integration.test.ts +411 -0
- package/src/platforms/kakaotalk/client.test.ts +3 -0
- package/src/platforms/kakaotalk/client.ts +108 -9
- package/src/platforms/kakaotalk/listener.test.ts +155 -149
- package/src/platforms/kakaotalk/listener.ts +46 -80
- package/src/platforms/telegrambot/cli.ts +34 -0
- package/src/platforms/telegrambot/client.test.ts +454 -0
- package/src/platforms/telegrambot/client.ts +404 -0
- package/src/platforms/telegrambot/commands/auth.test.ts +244 -0
- package/src/platforms/telegrambot/commands/auth.ts +220 -0
- package/src/platforms/telegrambot/commands/chat.ts +96 -0
- package/src/platforms/telegrambot/commands/index.ts +5 -0
- package/src/platforms/telegrambot/commands/message.ts +235 -0
- package/src/platforms/telegrambot/commands/reaction.ts +70 -0
- package/src/platforms/telegrambot/commands/shared.ts +32 -0
- package/src/platforms/telegrambot/commands/whoami.ts +45 -0
- package/src/platforms/telegrambot/credential-manager.test.ts +196 -0
- package/src/platforms/telegrambot/credential-manager.ts +141 -0
- package/src/platforms/telegrambot/index.ts +44 -0
- package/src/platforms/telegrambot/listener.test.ts +398 -0
- package/src/platforms/telegrambot/listener.ts +198 -0
- package/src/platforms/telegrambot/types.test.ts +128 -0
- 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
|
-
|
|
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
|
}
|