agent-messenger 2.12.1 → 2.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +1 -1
- package/dist/package.json +1 -1
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts +18 -0
- package/dist/src/platforms/kakaotalk/chat-classifier.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/chat-classifier.js +29 -0
- package/dist/src/platforms/kakaotalk/chat-classifier.js.map +1 -0
- package/dist/src/platforms/kakaotalk/cli.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/cli.js +2 -1
- package/dist/src/platforms/kakaotalk/cli.js.map +1 -1
- package/dist/src/platforms/kakaotalk/client.d.ts +35 -1
- package/dist/src/platforms/kakaotalk/client.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/client.js +318 -15
- package/dist/src/platforms/kakaotalk/client.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/chat.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/chat.js +2 -1
- package/dist/src/platforms/kakaotalk/commands/chat.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/index.d.ts +1 -0
- package/dist/src/platforms/kakaotalk/commands/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/index.js +1 -0
- package/dist/src/platforms/kakaotalk/commands/index.js.map +1 -1
- package/dist/src/platforms/kakaotalk/commands/member.d.ts +3 -0
- package/dist/src/platforms/kakaotalk/commands/member.d.ts.map +1 -0
- package/dist/src/platforms/kakaotalk/commands/member.js +22 -0
- package/dist/src/platforms/kakaotalk/commands/member.js.map +1 -0
- package/dist/src/platforms/kakaotalk/index.d.ts +4 -2
- package/dist/src/platforms/kakaotalk/index.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/index.js +2 -1
- package/dist/src/platforms/kakaotalk/index.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 +48 -74
- package/dist/src/platforms/kakaotalk/listener.js.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts +28 -0
- package/dist/src/platforms/kakaotalk/protocol/session.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/protocol/session.js +44 -0
- package/dist/src/platforms/kakaotalk/protocol/session.js.map +1 -1
- package/dist/src/platforms/kakaotalk/types.d.ts +37 -0
- package/dist/src/platforms/kakaotalk/types.d.ts.map +1 -1
- package/dist/src/platforms/kakaotalk/types.js +17 -0
- package/dist/src/platforms/kakaotalk/types.js.map +1 -1
- package/dist/src/platforms/slackbot/client.d.ts +5 -0
- package/dist/src/platforms/slackbot/client.d.ts.map +1 -1
- package/dist/src/platforms/slackbot/client.js +5 -0
- package/dist/src/platforms/slackbot/client.js.map +1 -1
- package/dist/src/tui/adapters/kakaotalk-adapter.js +3 -3
- package/dist/src/tui/adapters/kakaotalk-adapter.js.map +1 -1
- package/docs/content/docs/cli/kakaotalk.mdx +26 -1
- package/docs/content/docs/sdk/kakaotalk.mdx +45 -13
- package/docs/content/docs/sdk/slackbot.mdx +11 -0
- package/package.json +1 -1
- package/scripts/kakao-loco-capture.ts +466 -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 +30 -3
- package/skills/agent-kakaotalk/references/common-patterns.md +49 -1
- package/skills/agent-line/SKILL.md +1 -1
- package/skills/agent-slack/SKILL.md +1 -1
- package/skills/agent-slackbot/SKILL.md +1 -2
- package/skills/agent-teams/SKILL.md +1 -1
- package/skills/agent-telegram/SKILL.md +1 -1
- package/skills/agent-telegrambot/SKILL.md +1 -1
- 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/platforms/kakaotalk/chat-classifier.test.ts +33 -0
- package/src/platforms/kakaotalk/chat-classifier.ts +31 -0
- package/src/platforms/kakaotalk/cli.ts +2 -1
- package/src/platforms/kakaotalk/client-listener-integration.test.ts +411 -0
- package/src/platforms/kakaotalk/client.test.ts +785 -1
- package/src/platforms/kakaotalk/client.ts +369 -18
- package/src/platforms/kakaotalk/commands/chat.ts +3 -1
- package/src/platforms/kakaotalk/commands/index.ts +1 -0
- package/src/platforms/kakaotalk/commands/member.test.ts +102 -0
- package/src/platforms/kakaotalk/commands/member.ts +32 -0
- package/src/platforms/kakaotalk/index.test.ts +5 -0
- package/src/platforms/kakaotalk/index.ts +4 -0
- package/src/platforms/kakaotalk/listener.test.ts +184 -149
- package/src/platforms/kakaotalk/listener.ts +51 -82
- package/src/platforms/kakaotalk/protocol/session.ts +44 -0
- package/src/platforms/kakaotalk/types.ts +39 -0
- package/src/platforms/slackbot/client.test.ts +67 -0
- package/src/platforms/slackbot/client.ts +17 -1
- package/src/tui/adapters/kakaotalk-adapter.ts +3 -3
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test'
|
|
2
|
+
|
|
3
|
+
import { classifyKakaoChat } from './chat-classifier'
|
|
4
|
+
|
|
5
|
+
describe('classifyKakaoChat', () => {
|
|
6
|
+
it('classifies a normal DM (type 11, 2 members) as dm', () => {
|
|
7
|
+
expect(classifyKakaoChat({ type: 11, active_members: 2 })).toBe('dm')
|
|
8
|
+
})
|
|
9
|
+
|
|
10
|
+
it('classifies a normal group (type 10, 5 members) as group', () => {
|
|
11
|
+
expect(classifyKakaoChat({ type: 10, active_members: 5 })).toBe('group')
|
|
12
|
+
})
|
|
13
|
+
|
|
14
|
+
it('classifies legacy DM (type 9, 2 members) as dm via member-count fallback', () => {
|
|
15
|
+
expect(classifyKakaoChat({ type: 9, active_members: 2 })).toBe('dm')
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
it('classifies a 3-member group (type 10) as group, not dm', () => {
|
|
19
|
+
expect(classifyKakaoChat({ type: 10, active_members: 3 })).toBe('group')
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
for (const type of [2, 13, 14, 15, 16]) {
|
|
23
|
+
it(`classifies open-chat type ${type} as open regardless of member count`, () => {
|
|
24
|
+
expect(classifyKakaoChat({ type, active_members: 1 })).toBe('open')
|
|
25
|
+
expect(classifyKakaoChat({ type, active_members: 2 })).toBe('open')
|
|
26
|
+
expect(classifyKakaoChat({ type, active_members: 50 })).toBe('open')
|
|
27
|
+
})
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
it('classifies a 1-member chat (lone room) as dm', () => {
|
|
31
|
+
expect(classifyKakaoChat({ type: 11, active_members: 1 })).toBe('dm')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import type { KakaoChat } from './types'
|
|
2
|
+
|
|
3
|
+
export type KakaoChatKind = 'dm' | 'group' | 'open' | 'unknown'
|
|
4
|
+
|
|
5
|
+
// OpenChat-family `type` codes observed on the wire. KakaoTalk's LOCO
|
|
6
|
+
// protocol exposes a numeric `type` field with no documented mapping; these
|
|
7
|
+
// five codes consistently identify OpenChat rooms across normal OpenChat,
|
|
8
|
+
// OpenChat DMs, and the various OpenChat sub-types seen in production.
|
|
9
|
+
const OPEN_CHAT_TYPE_CODES: ReadonlySet<number> = new Set([2, 13, 14, 15, 16])
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Classify a KakaoTalk chat as `'dm'`, `'group'`, `'open'`, or `'unknown'`.
|
|
13
|
+
*
|
|
14
|
+
* REGRESSION GUARD: An earlier implementation hard-coded `0=dm`, `1=group`,
|
|
15
|
+
* `2=open` on the raw `type` number. Modern KakaoTalk uses codes like `11`
|
|
16
|
+
* for normal DMs and `10` for normal groups, so the old mapping silently
|
|
17
|
+
* classified every real DM as `'unknown'` and bucketed it as a group. Do
|
|
18
|
+
* NOT "simplify" this back to a pure type-code mapping without verifying
|
|
19
|
+
* against a real KakaoTalk session.
|
|
20
|
+
*
|
|
21
|
+
* `'unknown'` is reserved for future protocol drift; the current heuristic
|
|
22
|
+
* never returns it, but it is part of the union so consumers can handle
|
|
23
|
+
* the case defensively.
|
|
24
|
+
*/
|
|
25
|
+
export function classifyKakaoChat(chat: Pick<KakaoChat, 'type' | 'active_members'>): KakaoChatKind {
|
|
26
|
+
if (OPEN_CHAT_TYPE_CODES.has(chat.type)) return 'open'
|
|
27
|
+
// active_members counts the logged-in user, so a 1:1 DM is exactly 2
|
|
28
|
+
// (self + one other) and a "lone" room with only self is 1.
|
|
29
|
+
if (chat.active_members <= 2) return 'dm'
|
|
30
|
+
return 'group'
|
|
31
|
+
}
|
|
@@ -4,7 +4,7 @@ import type { Command as CommandType } from 'commander'
|
|
|
4
4
|
import { Command } from 'commander'
|
|
5
5
|
|
|
6
6
|
import pkg from '../../../package.json' with { type: 'json' }
|
|
7
|
-
import { authCommand, chatCommand, messageCommand, whoamiCommand } from './commands/index'
|
|
7
|
+
import { authCommand, chatCommand, memberCommand, messageCommand, whoamiCommand } from './commands/index'
|
|
8
8
|
import { ensureKakaoAuth } from './ensure-auth'
|
|
9
9
|
|
|
10
10
|
function isAuthCommand(command: CommandType): boolean {
|
|
@@ -30,6 +30,7 @@ program.hook('preAction', async (_thisCommand, actionCommand) => {
|
|
|
30
30
|
|
|
31
31
|
program.addCommand(authCommand)
|
|
32
32
|
program.addCommand(chatCommand)
|
|
33
|
+
program.addCommand(memberCommand)
|
|
33
34
|
program.addCommand(messageCommand)
|
|
34
35
|
program.addCommand(whoamiCommand)
|
|
35
36
|
|
|
@@ -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
|
+
})
|