bingocode 1.0.40 → 1.1.42
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/bin/bingo-win.cjs +2 -1
- package/bin/bingocode-win.cjs +2 -1
- package/bin/claude-win.cjs +2 -1
- package/bun.lock +1716 -0
- package/package.json +14 -2
- package/src/server/config/providers.yaml +1 -1
- package/src/server/proxy/transform/anthropicToOpenaiChat.ts +23 -9
- package/adapters/README.md +0 -87
- package/adapters/common/__tests__/chat-queue.test.ts +0 -61
- package/adapters/common/__tests__/format.test.ts +0 -148
- package/adapters/common/__tests__/http-client.test.ts +0 -105
- package/adapters/common/__tests__/message-buffer.test.ts +0 -84
- package/adapters/common/__tests__/message-dedup.test.ts +0 -57
- package/adapters/common/__tests__/session-store.test.ts +0 -62
- package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
- package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
- package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
- package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
- package/adapters/common/attachment/attachment-limits.ts +0 -58
- package/adapters/common/attachment/attachment-store.ts +0 -121
- package/adapters/common/attachment/attachment-types.ts +0 -29
- package/adapters/common/attachment/image-block-watcher.ts +0 -94
- package/adapters/common/chat-queue.ts +0 -24
- package/adapters/common/config.ts +0 -96
- package/adapters/common/format.ts +0 -229
- package/adapters/common/http-client.ts +0 -107
- package/adapters/common/message-buffer.ts +0 -91
- package/adapters/common/message-dedup.ts +0 -57
- package/adapters/common/pairing.ts +0 -149
- package/adapters/common/session-store.ts +0 -60
- package/adapters/common/ws-bridge.ts +0 -282
- package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
- package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
- package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
- package/adapters/feishu/__tests__/feishu.test.ts +0 -907
- package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
- package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
- package/adapters/feishu/__tests__/media.test.ts +0 -120
- package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
- package/adapters/feishu/card-errors.ts +0 -151
- package/adapters/feishu/cardkit.ts +0 -294
- package/adapters/feishu/extract-payload.ts +0 -95
- package/adapters/feishu/flush-controller.ts +0 -149
- package/adapters/feishu/index.ts +0 -1275
- package/adapters/feishu/markdown-style.ts +0 -212
- package/adapters/feishu/media.ts +0 -176
- package/adapters/feishu/streaming-card.ts +0 -612
- package/adapters/package.json +0 -23
- package/adapters/telegram/__tests__/media.test.ts +0 -86
- package/adapters/telegram/__tests__/telegram.test.ts +0 -115
- package/adapters/telegram/index.ts +0 -754
- package/adapters/telegram/media.ts +0 -89
- package/adapters/tsconfig.json +0 -18
- package/runtime/mac_helper.py +0 -775
- package/runtime/requirements-win.txt +0 -7
- package/runtime/requirements.txt +0 -6
- package/runtime/test_helpers.py +0 -322
- package/runtime/win_helper.py +0 -723
- package/scripts/count-app-loc.ts +0 -256
- package/scripts/release.ts +0 -130
- package/start-cli.bat +0 -7
- package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
- package/stubs/color-diff-napi.ts +0 -45
|
@@ -1,91 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 流式消息缓冲
|
|
3
|
-
*
|
|
4
|
-
* 将 content_delta 累积后按时间窗口或字符数批量 flush。
|
|
5
|
-
* 用于 Telegram editMessage / 飞书流式卡片更新。
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
export type FlushCallback = (text: string, isComplete: boolean) => void | Promise<void>
|
|
9
|
-
|
|
10
|
-
const DEFAULT_INTERVAL_MS = 500
|
|
11
|
-
const DEFAULT_CHAR_THRESHOLD = 200
|
|
12
|
-
|
|
13
|
-
export class MessageBuffer {
|
|
14
|
-
private buffer = ''
|
|
15
|
-
private timer: ReturnType<typeof setTimeout> | null = null
|
|
16
|
-
private flushing = false
|
|
17
|
-
private pendingComplete = false
|
|
18
|
-
|
|
19
|
-
constructor(
|
|
20
|
-
private onFlush: FlushCallback,
|
|
21
|
-
private intervalMs = DEFAULT_INTERVAL_MS,
|
|
22
|
-
private charThreshold = DEFAULT_CHAR_THRESHOLD,
|
|
23
|
-
) {}
|
|
24
|
-
|
|
25
|
-
/** Append text delta. Triggers flush if threshold reached. */
|
|
26
|
-
append(text: string): void {
|
|
27
|
-
this.buffer += text
|
|
28
|
-
if (this.buffer.length >= this.charThreshold) {
|
|
29
|
-
this.scheduleFlush()
|
|
30
|
-
} else if (!this.timer) {
|
|
31
|
-
this.timer = setTimeout(() => this.flush(false), this.intervalMs)
|
|
32
|
-
}
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/** Immediately flush all remaining content (called on message_complete). */
|
|
36
|
-
async complete(): Promise<void> {
|
|
37
|
-
if (this.timer) {
|
|
38
|
-
clearTimeout(this.timer)
|
|
39
|
-
this.timer = null
|
|
40
|
-
}
|
|
41
|
-
if (this.flushing) {
|
|
42
|
-
// A flush is in-flight; mark pending so it fires after current flush finishes
|
|
43
|
-
this.pendingComplete = true
|
|
44
|
-
return
|
|
45
|
-
}
|
|
46
|
-
await this.flush(true)
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Reset the buffer for a new message. */
|
|
50
|
-
reset(): void {
|
|
51
|
-
this.buffer = ''
|
|
52
|
-
this.pendingComplete = false
|
|
53
|
-
if (this.timer) {
|
|
54
|
-
clearTimeout(this.timer)
|
|
55
|
-
this.timer = null
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
private scheduleFlush(): void {
|
|
60
|
-
if (this.timer) {
|
|
61
|
-
clearTimeout(this.timer)
|
|
62
|
-
this.timer = null
|
|
63
|
-
}
|
|
64
|
-
queueMicrotask(() => this.flush(false))
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
private async flush(isComplete: boolean): Promise<void> {
|
|
68
|
-
if (this.timer) {
|
|
69
|
-
clearTimeout(this.timer)
|
|
70
|
-
this.timer = null
|
|
71
|
-
}
|
|
72
|
-
if (this.flushing) return
|
|
73
|
-
if (this.buffer.length === 0) return
|
|
74
|
-
|
|
75
|
-
this.flushing = true
|
|
76
|
-
const text = this.buffer
|
|
77
|
-
this.buffer = ''
|
|
78
|
-
try {
|
|
79
|
-
await this.onFlush(text, isComplete)
|
|
80
|
-
} catch (err) {
|
|
81
|
-
console.error('[MessageBuffer] Flush error:', err)
|
|
82
|
-
} finally {
|
|
83
|
-
this.flushing = false
|
|
84
|
-
// If complete() was called while we were flushing, do the final flush now
|
|
85
|
-
if (this.pendingComplete) {
|
|
86
|
-
this.pendingComplete = false
|
|
87
|
-
await this.flush(true)
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 消息去重
|
|
3
|
-
*
|
|
4
|
-
* 防止 WebSocket 重连等场景下消息重复处理。
|
|
5
|
-
* 参考 openclaw-lark dedup.ts 的 Map + TTL + 容量 设计。
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
const DEFAULT_TTL_MS = 10 * 60_000 // 10 minutes
|
|
9
|
-
const DEFAULT_MAX_ENTRIES = 5000
|
|
10
|
-
const SWEEP_INTERVAL_MS = 60_000 // 1 minute
|
|
11
|
-
|
|
12
|
-
export class MessageDedup {
|
|
13
|
-
private store = new Map<string, number>()
|
|
14
|
-
private sweepTimer: ReturnType<typeof setInterval>
|
|
15
|
-
|
|
16
|
-
constructor(
|
|
17
|
-
private ttlMs = DEFAULT_TTL_MS,
|
|
18
|
-
private maxEntries = DEFAULT_MAX_ENTRIES,
|
|
19
|
-
) {
|
|
20
|
-
this.sweepTimer = setInterval(() => this.sweep(), SWEEP_INTERVAL_MS)
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/** Returns true if this is a NEW message, false if duplicate. */
|
|
24
|
-
tryRecord(id: string): boolean {
|
|
25
|
-
const now = Date.now()
|
|
26
|
-
const existing = this.store.get(id)
|
|
27
|
-
|
|
28
|
-
if (existing !== undefined && now - existing < this.ttlMs) {
|
|
29
|
-
return false // duplicate
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Evict oldest if at capacity
|
|
33
|
-
if (this.store.size >= this.maxEntries) {
|
|
34
|
-
const oldest = this.store.keys().next().value
|
|
35
|
-
if (oldest !== undefined) this.store.delete(oldest)
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
this.store.set(id, now)
|
|
39
|
-
return true
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
private sweep(): void {
|
|
43
|
-
const now = Date.now()
|
|
44
|
-
for (const [key, ts] of this.store) {
|
|
45
|
-
if (now - ts >= this.ttlMs) {
|
|
46
|
-
this.store.delete(key)
|
|
47
|
-
} else {
|
|
48
|
-
break // Map preserves insertion order; once fresh, rest is fresh
|
|
49
|
-
}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
destroy(): void {
|
|
54
|
-
clearInterval(this.sweepTimer)
|
|
55
|
-
this.store.clear()
|
|
56
|
-
}
|
|
57
|
-
}
|
|
@@ -1,149 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 配对核心逻辑
|
|
3
|
-
*
|
|
4
|
-
* - generatePairingCode(): 生成 6 位安全配对码
|
|
5
|
-
* - isPaired(): 检查用户是否已配对(pairedUsers + allowedUsers 并集)
|
|
6
|
-
* - tryPair(): 验证配对码,成功则写入 pairedUsers 并清除 code
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
|
-
import * as fs from 'node:fs'
|
|
10
|
-
import * as os from 'node:os'
|
|
11
|
-
import * as path from 'node:path'
|
|
12
|
-
import * as crypto from 'node:crypto'
|
|
13
|
-
import type { PairedUser, PairingState } from './config.js'
|
|
14
|
-
|
|
15
|
-
const SAFE_ALPHABET = 'ABCDEFGHJKMNPQRSTUVWXYZ23456789' // 排除 0/O/1/I/L
|
|
16
|
-
|
|
17
|
-
// 速率限制:每个 userId 在 RATE_LIMIT_WINDOW_MS 内最多 RATE_LIMIT_MAX_ATTEMPTS 次失败尝试
|
|
18
|
-
const RATE_LIMIT_WINDOW_MS = 5 * 60 * 1000 // 5 minutes
|
|
19
|
-
const RATE_LIMIT_MAX_ATTEMPTS = 5
|
|
20
|
-
const failedAttempts = new Map<string, { count: number; firstAttempt: number }>()
|
|
21
|
-
|
|
22
|
-
function isRateLimited(userId: string | number): boolean {
|
|
23
|
-
const key = String(userId)
|
|
24
|
-
const record = failedAttempts.get(key)
|
|
25
|
-
if (!record) return false
|
|
26
|
-
if (Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
27
|
-
failedAttempts.delete(key)
|
|
28
|
-
return false
|
|
29
|
-
}
|
|
30
|
-
return record.count >= RATE_LIMIT_MAX_ATTEMPTS
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
function recordFailedAttempt(userId: string | number): void {
|
|
34
|
-
const key = String(userId)
|
|
35
|
-
const record = failedAttempts.get(key)
|
|
36
|
-
if (!record || Date.now() - record.firstAttempt > RATE_LIMIT_WINDOW_MS) {
|
|
37
|
-
failedAttempts.set(key, { count: 1, firstAttempt: Date.now() })
|
|
38
|
-
} else {
|
|
39
|
-
record.count++
|
|
40
|
-
}
|
|
41
|
-
}
|
|
42
|
-
const CODE_LENGTH = 6
|
|
43
|
-
const CODE_TTL_MS = 60 * 60 * 1000 // 60 minutes
|
|
44
|
-
|
|
45
|
-
function getConfigPath(): string {
|
|
46
|
-
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
47
|
-
return path.join(configDir, 'adapters.json')
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
function readConfigFile(): Record<string, any> {
|
|
51
|
-
try {
|
|
52
|
-
return JSON.parse(fs.readFileSync(getConfigPath(), 'utf-8'))
|
|
53
|
-
} catch {
|
|
54
|
-
return {}
|
|
55
|
-
}
|
|
56
|
-
}
|
|
57
|
-
|
|
58
|
-
function writeConfigFile(data: Record<string, any>): void {
|
|
59
|
-
const filePath = getConfigPath()
|
|
60
|
-
const dir = path.dirname(filePath)
|
|
61
|
-
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true })
|
|
62
|
-
const tmp = `${filePath}.tmp.${Date.now()}`
|
|
63
|
-
fs.writeFileSync(tmp, JSON.stringify(data, null, 2) + '\n', 'utf-8')
|
|
64
|
-
fs.renameSync(tmp, filePath)
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function generatePairingCode(): string {
|
|
68
|
-
let code = ''
|
|
69
|
-
for (let i = 0; i < CODE_LENGTH; i++) {
|
|
70
|
-
code += SAFE_ALPHABET[crypto.randomInt(SAFE_ALPHABET.length)]
|
|
71
|
-
}
|
|
72
|
-
return code
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
/** 检查用户是否已配对(pairedUsers + allowedUsers 并集) */
|
|
76
|
-
export function isPaired(
|
|
77
|
-
platform: 'telegram' | 'feishu',
|
|
78
|
-
userId: string | number,
|
|
79
|
-
config: Record<string, any>,
|
|
80
|
-
): boolean {
|
|
81
|
-
const platformConfig = config[platform] ?? {}
|
|
82
|
-
const allowedUsers: (string | number)[] = platformConfig.allowedUsers ?? []
|
|
83
|
-
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
|
|
84
|
-
|
|
85
|
-
// allowedUsers 非空时检查
|
|
86
|
-
if (allowedUsers.length > 0 && allowedUsers.includes(userId)) return true
|
|
87
|
-
// 默认关闭:没有配置任何用户时拒绝访问(需要先配对)
|
|
88
|
-
if (pairedUsers.length === 0 && allowedUsers.length === 0) return false
|
|
89
|
-
|
|
90
|
-
return pairedUsers.some((p) => String(p.userId) === String(userId))
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
/**
|
|
94
|
-
* 尝试配对:验证消息文本是否匹配当前有效配对码。
|
|
95
|
-
* 成功则写入 pairedUsers 并清除 pairing.code,返回 true。
|
|
96
|
-
*/
|
|
97
|
-
export function tryPair(
|
|
98
|
-
messageText: string,
|
|
99
|
-
senderInfo: { userId: string | number; displayName: string },
|
|
100
|
-
platform: 'telegram' | 'feishu',
|
|
101
|
-
): boolean {
|
|
102
|
-
const file = readConfigFile()
|
|
103
|
-
const pairing: PairingState = file.pairing ?? { code: null, expiresAt: null, createdAt: null }
|
|
104
|
-
|
|
105
|
-
// 速率限制检查
|
|
106
|
-
if (isRateLimited(senderInfo.userId)) return false
|
|
107
|
-
|
|
108
|
-
// 检查配对码是否有效
|
|
109
|
-
if (!pairing.code || !pairing.expiresAt) return false
|
|
110
|
-
if (Date.now() > pairing.expiresAt) return false
|
|
111
|
-
|
|
112
|
-
// 比较(忽略大小写和空格)
|
|
113
|
-
const input = messageText.trim().toUpperCase()
|
|
114
|
-
if (input !== pairing.code.toUpperCase()) {
|
|
115
|
-
recordFailedAttempt(senderInfo.userId)
|
|
116
|
-
return false
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
// 配对成功:写入 pairedUsers
|
|
120
|
-
const platformConfig = file[platform] ?? {}
|
|
121
|
-
const pairedUsers: PairedUser[] = platformConfig.pairedUsers ?? []
|
|
122
|
-
|
|
123
|
-
// 避免重复
|
|
124
|
-
const exists = pairedUsers.some((p) => String(p.userId) === String(senderInfo.userId))
|
|
125
|
-
if (!exists) {
|
|
126
|
-
pairedUsers.push({
|
|
127
|
-
userId: senderInfo.userId,
|
|
128
|
-
displayName: senderInfo.displayName,
|
|
129
|
-
pairedAt: Date.now(),
|
|
130
|
-
})
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// 更新 config
|
|
134
|
-
file[platform] = { ...platformConfig, pairedUsers }
|
|
135
|
-
file.pairing = { code: null, expiresAt: null, createdAt: null } // 一次性使用
|
|
136
|
-
writeConfigFile(file)
|
|
137
|
-
|
|
138
|
-
return true
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** 统一的用户授权检查(供各 adapter 调用) */
|
|
142
|
-
export function isAllowedUser(platform: 'telegram' | 'feishu', userId: string | number): boolean {
|
|
143
|
-
try {
|
|
144
|
-
const cfgFile = readConfigFile()
|
|
145
|
-
return isPaired(platform, userId, cfgFile)
|
|
146
|
-
} catch {
|
|
147
|
-
return false
|
|
148
|
-
}
|
|
149
|
-
}
|
|
@@ -1,60 +0,0 @@
|
|
|
1
|
-
import * as fs from 'node:fs'
|
|
2
|
-
import * as path from 'node:path'
|
|
3
|
-
import * as os from 'node:os'
|
|
4
|
-
|
|
5
|
-
export type SessionEntry = {
|
|
6
|
-
sessionId: string
|
|
7
|
-
workDir: string
|
|
8
|
-
updatedAt: number
|
|
9
|
-
}
|
|
10
|
-
|
|
11
|
-
type StoreData = Record<string, SessionEntry>
|
|
12
|
-
|
|
13
|
-
function getDefaultPath(): string {
|
|
14
|
-
const configDir = process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude')
|
|
15
|
-
return path.join(configDir, 'adapter-sessions.json')
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
export class SessionStore {
|
|
19
|
-
private data: StoreData
|
|
20
|
-
private filePath: string
|
|
21
|
-
|
|
22
|
-
constructor(filePath?: string) {
|
|
23
|
-
this.filePath = filePath ?? getDefaultPath()
|
|
24
|
-
this.data = this.load()
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
get(chatId: string): SessionEntry | null {
|
|
28
|
-
return this.data[chatId] ?? null
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
set(chatId: string, sessionId: string, workDir: string): void {
|
|
32
|
-
this.data[chatId] = { sessionId, workDir, updatedAt: Date.now() }
|
|
33
|
-
this.save()
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
delete(chatId: string): void {
|
|
37
|
-
delete this.data[chatId]
|
|
38
|
-
this.save()
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
listAll(): Array<{ chatId: string } & SessionEntry> {
|
|
42
|
-
return Object.entries(this.data).map(([chatId, entry]) => ({ chatId, ...entry }))
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
private load(): StoreData {
|
|
46
|
-
try {
|
|
47
|
-
return JSON.parse(fs.readFileSync(this.filePath, 'utf-8'))
|
|
48
|
-
} catch {
|
|
49
|
-
return {}
|
|
50
|
-
}
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
private save(): void {
|
|
54
|
-
const dir = path.dirname(this.filePath)
|
|
55
|
-
fs.mkdirSync(dir, { recursive: true })
|
|
56
|
-
const tmp = `${this.filePath}.tmp.${Date.now()}`
|
|
57
|
-
fs.writeFileSync(tmp, JSON.stringify(this.data, null, 2) + '\n')
|
|
58
|
-
fs.renameSync(tmp, this.filePath)
|
|
59
|
-
}
|
|
60
|
-
}
|
|
@@ -1,282 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* WebSocket Bridge
|
|
3
|
-
*
|
|
4
|
-
* 封装与 Claude Code Desktop 服务端 /ws/:sessionId 的通信。
|
|
5
|
-
* 管理 chatId → sessionId 映射,自动重连,心跳。
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import WebSocket from 'ws'
|
|
9
|
-
|
|
10
|
-
/** Attachment reference — mirrors src/server/ws/events.ts AttachmentRef.
|
|
11
|
-
* The server will either (a) write base64 `data` to
|
|
12
|
-
* ~/.claude/uploads/{sessionId}/ and convert to ImageBlockParam, or
|
|
13
|
-
* (b) read `path` from disk and inject `@"path"` into the prompt. */
|
|
14
|
-
export type AttachmentRef = {
|
|
15
|
-
type: 'file' | 'image'
|
|
16
|
-
name?: string
|
|
17
|
-
path?: string
|
|
18
|
-
data?: string // base64 payload (images)
|
|
19
|
-
mimeType?: string
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** Server → Client message (mirrors src/server/ws/events.ts ServerMessage) */
|
|
23
|
-
export type ServerMessage = {
|
|
24
|
-
type: string
|
|
25
|
-
[key: string]: any
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/** Callback for server messages */
|
|
29
|
-
export type MessageHandler = (msg: ServerMessage) => void
|
|
30
|
-
|
|
31
|
-
type Session = {
|
|
32
|
-
sessionId: string
|
|
33
|
-
ws: WebSocket
|
|
34
|
-
reconnectAttempts: number
|
|
35
|
-
reconnectTimer: ReturnType<typeof setTimeout> | null
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const HEARTBEAT_INTERVAL_MS = 30_000
|
|
39
|
-
const RECONNECT_BASE_MS = 1000
|
|
40
|
-
const RECONNECT_MAX_MS = 30_000
|
|
41
|
-
const MAX_RECONNECT_ATTEMPTS = 10
|
|
42
|
-
|
|
43
|
-
export class WsBridge {
|
|
44
|
-
private sessions = new Map<string, Session>()
|
|
45
|
-
/** Single handler per chatId — separate from sessions so reconnect doesn't duplicate */
|
|
46
|
-
private handlers = new Map<string, MessageHandler>()
|
|
47
|
-
/** Per-chat FIFO queue of in-flight handler promises.
|
|
48
|
-
* Ensures an async handler for message N completes before handler for N+1
|
|
49
|
-
* starts, preventing state races at `await` points. */
|
|
50
|
-
private handlerChains = new Map<string, Promise<void>>()
|
|
51
|
-
private serverUrl: string
|
|
52
|
-
private platform: string
|
|
53
|
-
private heartbeatTimer: ReturnType<typeof setInterval> | null = null
|
|
54
|
-
private destroyed = false
|
|
55
|
-
|
|
56
|
-
constructor(serverUrl: string, platform: string) {
|
|
57
|
-
this.serverUrl = serverUrl.replace(/\/$/, '')
|
|
58
|
-
this.platform = platform
|
|
59
|
-
this.startHeartbeat()
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
/** Connect to a session with a known sessionId. Returns false if already connected. */
|
|
63
|
-
connectSession(chatId: string, sessionId: string): boolean {
|
|
64
|
-
const existing = this.sessions.get(chatId)
|
|
65
|
-
if (existing && existing.ws.readyState === WebSocket.OPEN) {
|
|
66
|
-
return false
|
|
67
|
-
}
|
|
68
|
-
this.connect(chatId, sessionId)
|
|
69
|
-
return true
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Send a user message to the session bound to chatId. */
|
|
73
|
-
sendUserMessage(
|
|
74
|
-
chatId: string,
|
|
75
|
-
content: string,
|
|
76
|
-
attachments?: AttachmentRef[],
|
|
77
|
-
): boolean {
|
|
78
|
-
const payload: Record<string, unknown> = { type: 'user_message', content }
|
|
79
|
-
if (attachments && attachments.length > 0) {
|
|
80
|
-
payload.attachments = attachments
|
|
81
|
-
}
|
|
82
|
-
return this.send(chatId, payload)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
/** Respond to a permission request.
|
|
86
|
-
*
|
|
87
|
-
* @param rule - optional rule name to make the permission persistent.
|
|
88
|
-
* Currently the server supports `'always'`, which uses the CLI's
|
|
89
|
-
* permission_suggestions to produce updatedPermissions so the same
|
|
90
|
-
* tool call won't prompt again in this session. Omit for one-shot allow. */
|
|
91
|
-
sendPermissionResponse(
|
|
92
|
-
chatId: string,
|
|
93
|
-
requestId: string,
|
|
94
|
-
allowed: boolean,
|
|
95
|
-
rule?: string,
|
|
96
|
-
): boolean {
|
|
97
|
-
const message: Record<string, unknown> = {
|
|
98
|
-
type: 'permission_response',
|
|
99
|
-
requestId,
|
|
100
|
-
allowed,
|
|
101
|
-
}
|
|
102
|
-
if (rule) message.rule = rule
|
|
103
|
-
return this.send(chatId, message)
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Stop the current generation. */
|
|
107
|
-
sendStopGeneration(chatId: string): boolean {
|
|
108
|
-
return this.send(chatId, { type: 'stop_generation' })
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
/** Register (or replace) the handler for server messages on a specific chatId. */
|
|
112
|
-
onServerMessage(chatId: string, handler: MessageHandler): void {
|
|
113
|
-
this.handlers.set(chatId, handler)
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/** Reset session for a chatId (e.g. /new command). */
|
|
117
|
-
resetSession(chatId: string): void {
|
|
118
|
-
const session = this.sessions.get(chatId)
|
|
119
|
-
if (session) {
|
|
120
|
-
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
|
|
121
|
-
session.ws.close(1000, 'session reset')
|
|
122
|
-
this.sessions.delete(chatId)
|
|
123
|
-
}
|
|
124
|
-
this.handlers.delete(chatId)
|
|
125
|
-
this.handlerChains.delete(chatId)
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
/** Has a session (connected or handler registered) for chatId. */
|
|
129
|
-
hasSession(chatId: string): boolean {
|
|
130
|
-
return this.sessions.has(chatId) || this.handlers.has(chatId)
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
/** Destroy all sessions. */
|
|
134
|
-
destroy(): void {
|
|
135
|
-
this.destroyed = true
|
|
136
|
-
if (this.heartbeatTimer) {
|
|
137
|
-
clearInterval(this.heartbeatTimer)
|
|
138
|
-
this.heartbeatTimer = null
|
|
139
|
-
}
|
|
140
|
-
for (const [, session] of this.sessions) {
|
|
141
|
-
if (session.reconnectTimer) clearTimeout(session.reconnectTimer)
|
|
142
|
-
session.ws.close(1000, 'bridge destroyed')
|
|
143
|
-
}
|
|
144
|
-
this.sessions.clear()
|
|
145
|
-
this.handlers.clear()
|
|
146
|
-
this.handlerChains.clear()
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// ------- internal -------
|
|
150
|
-
|
|
151
|
-
private connect(chatId: string, sessionId: string): void {
|
|
152
|
-
const url = `${this.serverUrl}/ws/${sessionId}`
|
|
153
|
-
const ws = new WebSocket(url)
|
|
154
|
-
|
|
155
|
-
// Cancel any pending reconnect timer for this chatId
|
|
156
|
-
const prev = this.sessions.get(chatId)
|
|
157
|
-
if (prev) {
|
|
158
|
-
if (prev.reconnectTimer) clearTimeout(prev.reconnectTimer)
|
|
159
|
-
prev.ws.removeAllListeners()
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
const session: Session = {
|
|
163
|
-
sessionId,
|
|
164
|
-
ws,
|
|
165
|
-
reconnectAttempts: prev?.reconnectAttempts ?? 0,
|
|
166
|
-
reconnectTimer: null,
|
|
167
|
-
}
|
|
168
|
-
this.sessions.set(chatId, session)
|
|
169
|
-
|
|
170
|
-
ws.on('open', () => {
|
|
171
|
-
console.log(`[WsBridge] Connected: ${sessionId}`)
|
|
172
|
-
session.reconnectAttempts = 0
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
ws.on('message', (raw) => {
|
|
176
|
-
let msg: ServerMessage
|
|
177
|
-
try {
|
|
178
|
-
msg = JSON.parse(raw.toString())
|
|
179
|
-
} catch (err) {
|
|
180
|
-
console.error('[WsBridge] Parse error:', err)
|
|
181
|
-
return
|
|
182
|
-
}
|
|
183
|
-
if (msg.type === 'pong') return
|
|
184
|
-
const handler = this.handlers.get(chatId)
|
|
185
|
-
if (!handler) return
|
|
186
|
-
|
|
187
|
-
// Serialize per-chat handler calls: chain each message onto the previous
|
|
188
|
-
// one so a slow handler (e.g. one awaiting im.message.create) fully
|
|
189
|
-
// finishes before the next message's handler runs. This prevents state
|
|
190
|
-
// races where a later message reads stale map entries set up by an
|
|
191
|
-
// earlier-but-still-in-flight handler.
|
|
192
|
-
const prev = this.handlerChains.get(chatId) ?? Promise.resolve()
|
|
193
|
-
const next = prev
|
|
194
|
-
.catch(() => {}) // upstream errors must not poison the chain
|
|
195
|
-
.then(() => Promise.resolve().then(() => handler(msg)))
|
|
196
|
-
.catch((err) => {
|
|
197
|
-
console.error(`[WsBridge] Handler error on ${chatId}:`, err)
|
|
198
|
-
})
|
|
199
|
-
this.handlerChains.set(chatId, next)
|
|
200
|
-
})
|
|
201
|
-
|
|
202
|
-
ws.on('close', (code, reason) => {
|
|
203
|
-
console.log(`[WsBridge] Disconnected: ${sessionId} (${code}: ${reason})`)
|
|
204
|
-
if (code === 1000) return
|
|
205
|
-
this.scheduleReconnect(chatId, sessionId)
|
|
206
|
-
})
|
|
207
|
-
|
|
208
|
-
ws.on('error', (err) => {
|
|
209
|
-
console.error(`[WsBridge] Error on ${sessionId}:`, err.message)
|
|
210
|
-
})
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
/** Wait until the WebSocket for chatId is open. Resolves false on timeout or error. */
|
|
214
|
-
waitForOpen(chatId: string, timeoutMs = 10_000): Promise<boolean> {
|
|
215
|
-
const session = this.sessions.get(chatId)
|
|
216
|
-
if (!session) return Promise.resolve(false)
|
|
217
|
-
if (session.ws.readyState === WebSocket.OPEN) return Promise.resolve(true)
|
|
218
|
-
return new Promise((resolve) => {
|
|
219
|
-
const timer = setTimeout(() => {
|
|
220
|
-
cleanup()
|
|
221
|
-
resolve(false)
|
|
222
|
-
}, timeoutMs)
|
|
223
|
-
const onOpen = () => { cleanup(); resolve(true) }
|
|
224
|
-
const onError = () => { cleanup(); resolve(false) }
|
|
225
|
-
const onClose = () => { cleanup(); resolve(false) }
|
|
226
|
-
const cleanup = () => {
|
|
227
|
-
clearTimeout(timer)
|
|
228
|
-
session.ws.removeListener('open', onOpen)
|
|
229
|
-
session.ws.removeListener('error', onError)
|
|
230
|
-
session.ws.removeListener('close', onClose)
|
|
231
|
-
}
|
|
232
|
-
session.ws.once('open', onOpen)
|
|
233
|
-
session.ws.once('error', onError)
|
|
234
|
-
session.ws.once('close', onClose)
|
|
235
|
-
})
|
|
236
|
-
}
|
|
237
|
-
|
|
238
|
-
private send(chatId: string, message: Record<string, unknown>): boolean {
|
|
239
|
-
const session = this.sessions.get(chatId)
|
|
240
|
-
if (!session || session.ws.readyState !== WebSocket.OPEN) {
|
|
241
|
-
console.warn(`[WsBridge] Cannot send to ${chatId}: session not ready`)
|
|
242
|
-
return false
|
|
243
|
-
}
|
|
244
|
-
session.ws.send(JSON.stringify(message))
|
|
245
|
-
return true
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
private scheduleReconnect(chatId: string, sessionId: string): void {
|
|
249
|
-
if (this.destroyed) return
|
|
250
|
-
const session = this.sessions.get(chatId)
|
|
251
|
-
if (!session) return
|
|
252
|
-
if (session.reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) {
|
|
253
|
-
console.error(`[WsBridge] Max reconnect attempts reached for ${sessionId}, giving up`)
|
|
254
|
-
this.sessions.delete(chatId)
|
|
255
|
-
this.handlers.delete(chatId)
|
|
256
|
-
return
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
session.reconnectAttempts++
|
|
260
|
-
const delay = Math.min(
|
|
261
|
-
RECONNECT_BASE_MS * Math.pow(2, session.reconnectAttempts - 1),
|
|
262
|
-
RECONNECT_MAX_MS,
|
|
263
|
-
)
|
|
264
|
-
console.log(`[WsBridge] Reconnecting ${sessionId} in ${delay}ms (attempt ${session.reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})`)
|
|
265
|
-
session.reconnectTimer = setTimeout(() => {
|
|
266
|
-
if (this.destroyed) return
|
|
267
|
-
if (this.sessions.get(chatId)?.sessionId === sessionId) {
|
|
268
|
-
this.connect(chatId, sessionId)
|
|
269
|
-
}
|
|
270
|
-
}, delay)
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
private startHeartbeat(): void {
|
|
274
|
-
this.heartbeatTimer = setInterval(() => {
|
|
275
|
-
for (const [, session] of this.sessions) {
|
|
276
|
-
if (session.ws.readyState === WebSocket.OPEN) {
|
|
277
|
-
session.ws.send(JSON.stringify({ type: 'ping' }))
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}, HEARTBEAT_INTERVAL_MS)
|
|
281
|
-
}
|
|
282
|
-
}
|