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.
Files changed (63) hide show
  1. package/bin/bingo-win.cjs +2 -1
  2. package/bin/bingocode-win.cjs +2 -1
  3. package/bin/claude-win.cjs +2 -1
  4. package/bun.lock +1716 -0
  5. package/package.json +14 -2
  6. package/src/server/config/providers.yaml +1 -1
  7. package/src/server/proxy/transform/anthropicToOpenaiChat.ts +23 -9
  8. package/adapters/README.md +0 -87
  9. package/adapters/common/__tests__/chat-queue.test.ts +0 -61
  10. package/adapters/common/__tests__/format.test.ts +0 -148
  11. package/adapters/common/__tests__/http-client.test.ts +0 -105
  12. package/adapters/common/__tests__/message-buffer.test.ts +0 -84
  13. package/adapters/common/__tests__/message-dedup.test.ts +0 -57
  14. package/adapters/common/__tests__/session-store.test.ts +0 -62
  15. package/adapters/common/__tests__/ws-bridge.test.ts +0 -177
  16. package/adapters/common/attachment/__tests__/attachment-limits.test.ts +0 -52
  17. package/adapters/common/attachment/__tests__/attachment-store.test.ts +0 -108
  18. package/adapters/common/attachment/__tests__/image-block-watcher.test.ts +0 -115
  19. package/adapters/common/attachment/attachment-limits.ts +0 -58
  20. package/adapters/common/attachment/attachment-store.ts +0 -121
  21. package/adapters/common/attachment/attachment-types.ts +0 -29
  22. package/adapters/common/attachment/image-block-watcher.ts +0 -94
  23. package/adapters/common/chat-queue.ts +0 -24
  24. package/adapters/common/config.ts +0 -96
  25. package/adapters/common/format.ts +0 -229
  26. package/adapters/common/http-client.ts +0 -107
  27. package/adapters/common/message-buffer.ts +0 -91
  28. package/adapters/common/message-dedup.ts +0 -57
  29. package/adapters/common/pairing.ts +0 -149
  30. package/adapters/common/session-store.ts +0 -60
  31. package/adapters/common/ws-bridge.ts +0 -282
  32. package/adapters/feishu/__tests__/card-errors.test.ts +0 -194
  33. package/adapters/feishu/__tests__/cardkit.test.ts +0 -295
  34. package/adapters/feishu/__tests__/extract-payload.test.ts +0 -77
  35. package/adapters/feishu/__tests__/feishu.test.ts +0 -907
  36. package/adapters/feishu/__tests__/flush-controller.test.ts +0 -290
  37. package/adapters/feishu/__tests__/markdown-style.test.ts +0 -353
  38. package/adapters/feishu/__tests__/media.test.ts +0 -120
  39. package/adapters/feishu/__tests__/streaming-card.test.ts +0 -914
  40. package/adapters/feishu/card-errors.ts +0 -151
  41. package/adapters/feishu/cardkit.ts +0 -294
  42. package/adapters/feishu/extract-payload.ts +0 -95
  43. package/adapters/feishu/flush-controller.ts +0 -149
  44. package/adapters/feishu/index.ts +0 -1275
  45. package/adapters/feishu/markdown-style.ts +0 -212
  46. package/adapters/feishu/media.ts +0 -176
  47. package/adapters/feishu/streaming-card.ts +0 -612
  48. package/adapters/package.json +0 -23
  49. package/adapters/telegram/__tests__/media.test.ts +0 -86
  50. package/adapters/telegram/__tests__/telegram.test.ts +0 -115
  51. package/adapters/telegram/index.ts +0 -754
  52. package/adapters/telegram/media.ts +0 -89
  53. package/adapters/tsconfig.json +0 -18
  54. package/runtime/mac_helper.py +0 -775
  55. package/runtime/requirements-win.txt +0 -7
  56. package/runtime/requirements.txt +0 -6
  57. package/runtime/test_helpers.py +0 -322
  58. package/runtime/win_helper.py +0 -723
  59. package/scripts/count-app-loc.ts +0 -256
  60. package/scripts/release.ts +0 -130
  61. package/start-cli.bat +0 -7
  62. package/stubs/ant-claude-for-chrome-mcp.ts +0 -24
  63. 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
- }