bingocode 1.0.41 → 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 +11 -4
- 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,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
|
-
}
|
|
@@ -1,194 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* card-errors 单元测试
|
|
3
|
-
*/
|
|
4
|
-
|
|
5
|
-
import { describe, it, expect } from 'bun:test'
|
|
6
|
-
import {
|
|
7
|
-
CARD_ERROR,
|
|
8
|
-
CARD_CONTENT_SUB_ERROR,
|
|
9
|
-
extractLarkApiCode,
|
|
10
|
-
extractSubCode,
|
|
11
|
-
parseCardApiError,
|
|
12
|
-
isCardRateLimitError,
|
|
13
|
-
isCardTableLimitError,
|
|
14
|
-
} from '../card-errors.js'
|
|
15
|
-
|
|
16
|
-
describe('extractLarkApiCode', () => {
|
|
17
|
-
it('从 err.code 直接提取', () => {
|
|
18
|
-
expect(extractLarkApiCode({ code: 230020 })).toBe(230020)
|
|
19
|
-
})
|
|
20
|
-
|
|
21
|
-
it('从 err.data.code 提取', () => {
|
|
22
|
-
expect(extractLarkApiCode({ data: { code: 230099 } })).toBe(230099)
|
|
23
|
-
})
|
|
24
|
-
|
|
25
|
-
it('从 err.response.data.code 提取(Axios 风格)', () => {
|
|
26
|
-
expect(extractLarkApiCode({ response: { data: { code: 99991672 } } })).toBe(99991672)
|
|
27
|
-
})
|
|
28
|
-
|
|
29
|
-
it('数字字符串被强制转成 number', () => {
|
|
30
|
-
expect(extractLarkApiCode({ code: '230020' })).toBe(230020)
|
|
31
|
-
})
|
|
32
|
-
|
|
33
|
-
it('三层结构优先级: err.code > err.data.code > err.response.data.code', () => {
|
|
34
|
-
const err = {
|
|
35
|
-
code: 1,
|
|
36
|
-
data: { code: 2 },
|
|
37
|
-
response: { data: { code: 3 } },
|
|
38
|
-
}
|
|
39
|
-
expect(extractLarkApiCode(err)).toBe(1)
|
|
40
|
-
})
|
|
41
|
-
|
|
42
|
-
it('none → undefined', () => {
|
|
43
|
-
expect(extractLarkApiCode({})).toBeUndefined()
|
|
44
|
-
expect(extractLarkApiCode(null)).toBeUndefined()
|
|
45
|
-
expect(extractLarkApiCode(undefined)).toBeUndefined()
|
|
46
|
-
expect(extractLarkApiCode('just a string')).toBeUndefined()
|
|
47
|
-
expect(extractLarkApiCode(new Error('plain'))).toBeUndefined()
|
|
48
|
-
})
|
|
49
|
-
|
|
50
|
-
it('非有限数字被忽略', () => {
|
|
51
|
-
expect(extractLarkApiCode({ code: NaN })).toBeUndefined()
|
|
52
|
-
expect(extractLarkApiCode({ code: 'not-a-number' })).toBeUndefined()
|
|
53
|
-
})
|
|
54
|
-
})
|
|
55
|
-
|
|
56
|
-
describe('extractSubCode', () => {
|
|
57
|
-
it('识别标准的 ErrCode: 11310', () => {
|
|
58
|
-
const msg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit'
|
|
59
|
-
expect(extractSubCode(msg)).toBe(11310)
|
|
60
|
-
})
|
|
61
|
-
|
|
62
|
-
it('无 ErrCode 时返回 null', () => {
|
|
63
|
-
expect(extractSubCode('random error message')).toBeNull()
|
|
64
|
-
expect(extractSubCode('')).toBeNull()
|
|
65
|
-
})
|
|
66
|
-
|
|
67
|
-
it('ErrCode 大小写容错(冒号后多空格)', () => {
|
|
68
|
-
expect(extractSubCode('ErrCode: 42')).toBe(42)
|
|
69
|
-
})
|
|
70
|
-
})
|
|
71
|
-
|
|
72
|
-
describe('parseCardApiError', () => {
|
|
73
|
-
it('从 SDK 风格错误提取完整结构', () => {
|
|
74
|
-
const err = { code: 230099, msg: 'ErrCode: 11310; ErrMsg: card table number over limit' }
|
|
75
|
-
const parsed = parseCardApiError(err)
|
|
76
|
-
expect(parsed).toEqual({
|
|
77
|
-
code: 230099,
|
|
78
|
-
subCode: 11310,
|
|
79
|
-
errMsg: 'ErrCode: 11310; ErrMsg: card table number over limit',
|
|
80
|
-
})
|
|
81
|
-
})
|
|
82
|
-
|
|
83
|
-
it('从 Axios 风格错误(response.data.msg)提取', () => {
|
|
84
|
-
const err = {
|
|
85
|
-
response: {
|
|
86
|
-
data: {
|
|
87
|
-
code: 230020,
|
|
88
|
-
msg: 'rate limited',
|
|
89
|
-
},
|
|
90
|
-
},
|
|
91
|
-
}
|
|
92
|
-
const parsed = parseCardApiError(err)
|
|
93
|
-
expect(parsed?.code).toBe(230020)
|
|
94
|
-
expect(parsed?.errMsg).toBe('rate limited')
|
|
95
|
-
expect(parsed?.subCode).toBeNull()
|
|
96
|
-
})
|
|
97
|
-
|
|
98
|
-
it('无 code 时返回 null', () => {
|
|
99
|
-
expect(parseCardApiError({})).toBeNull()
|
|
100
|
-
expect(parseCardApiError(null)).toBeNull()
|
|
101
|
-
expect(parseCardApiError('string')).toBeNull()
|
|
102
|
-
})
|
|
103
|
-
|
|
104
|
-
it('有 code 无 msg 时 errMsg 为空字符串', () => {
|
|
105
|
-
const parsed = parseCardApiError({ code: 230020 })
|
|
106
|
-
expect(parsed).toEqual({ code: 230020, subCode: null, errMsg: '' })
|
|
107
|
-
})
|
|
108
|
-
|
|
109
|
-
it('fallback 到 err.message', () => {
|
|
110
|
-
const err = Object.assign(new Error('fallback text'), { code: 230099 })
|
|
111
|
-
const parsed = parseCardApiError(err)
|
|
112
|
-
expect(parsed?.errMsg).toBe('fallback text')
|
|
113
|
-
})
|
|
114
|
-
})
|
|
115
|
-
|
|
116
|
-
describe('isCardRateLimitError', () => {
|
|
117
|
-
it('识别 230020', () => {
|
|
118
|
-
expect(isCardRateLimitError({ code: 230020 })).toBe(true)
|
|
119
|
-
})
|
|
120
|
-
|
|
121
|
-
it('识别 Axios 风格 230020', () => {
|
|
122
|
-
expect(isCardRateLimitError({ response: { data: { code: 230020 } } })).toBe(true)
|
|
123
|
-
})
|
|
124
|
-
|
|
125
|
-
it('不匹配其他 code', () => {
|
|
126
|
-
expect(isCardRateLimitError({ code: 230099 })).toBe(false)
|
|
127
|
-
expect(isCardRateLimitError({ code: 99991672 })).toBe(false)
|
|
128
|
-
})
|
|
129
|
-
|
|
130
|
-
it('非错误对象返回 false', () => {
|
|
131
|
-
expect(isCardRateLimitError(null)).toBe(false)
|
|
132
|
-
expect(isCardRateLimitError({})).toBe(false)
|
|
133
|
-
expect(isCardRateLimitError(new Error('random'))).toBe(false)
|
|
134
|
-
})
|
|
135
|
-
})
|
|
136
|
-
|
|
137
|
-
describe('isCardTableLimitError', () => {
|
|
138
|
-
const validMsg = 'Failed to create card content, ext=ErrCode: 11310; ErrMsg: card table number over limit; ErrorValue: table; '
|
|
139
|
-
|
|
140
|
-
it('严格三条件匹配: code=230099 + subCode=11310 + msg 含 table number over limit', () => {
|
|
141
|
-
const err = { code: CARD_ERROR.CARD_CONTENT_FAILED, msg: validMsg }
|
|
142
|
-
expect(isCardTableLimitError(err)).toBe(true)
|
|
143
|
-
})
|
|
144
|
-
|
|
145
|
-
it('从 Axios 风格的 response.data 匹配', () => {
|
|
146
|
-
const err = {
|
|
147
|
-
response: {
|
|
148
|
-
data: {
|
|
149
|
-
code: 230099,
|
|
150
|
-
msg: validMsg,
|
|
151
|
-
},
|
|
152
|
-
},
|
|
153
|
-
}
|
|
154
|
-
expect(isCardTableLimitError(err)).toBe(true)
|
|
155
|
-
})
|
|
156
|
-
|
|
157
|
-
it('230099 + 11310 但没有 "table number over limit" 字样 → false(其它元素超限)', () => {
|
|
158
|
-
const err = {
|
|
159
|
-
code: CARD_ERROR.CARD_CONTENT_FAILED,
|
|
160
|
-
msg: 'ErrCode: 11310; ErrMsg: some other element limit; ',
|
|
161
|
-
}
|
|
162
|
-
expect(isCardTableLimitError(err)).toBe(false)
|
|
163
|
-
})
|
|
164
|
-
|
|
165
|
-
it('code 不是 230099 → false', () => {
|
|
166
|
-
const err = { code: 230020, msg: validMsg }
|
|
167
|
-
expect(isCardTableLimitError(err)).toBe(false)
|
|
168
|
-
})
|
|
169
|
-
|
|
170
|
-
it('没有 subCode → false', () => {
|
|
171
|
-
const err = { code: 230099, msg: 'card table number over limit (no ErrCode)' }
|
|
172
|
-
expect(isCardTableLimitError(err)).toBe(false)
|
|
173
|
-
})
|
|
174
|
-
|
|
175
|
-
it('不区分 "table number" 的大小写', () => {
|
|
176
|
-
const err = {
|
|
177
|
-
code: 230099,
|
|
178
|
-
msg: 'ErrCode: 11310; ErrMsg: CARD TABLE NUMBER OVER LIMIT; ',
|
|
179
|
-
}
|
|
180
|
-
expect(isCardTableLimitError(err)).toBe(true)
|
|
181
|
-
})
|
|
182
|
-
})
|
|
183
|
-
|
|
184
|
-
describe('常量值', () => {
|
|
185
|
-
it('CARD_ERROR.RATE_LIMITED === 230020', () => {
|
|
186
|
-
expect(CARD_ERROR.RATE_LIMITED).toBe(230020)
|
|
187
|
-
})
|
|
188
|
-
it('CARD_ERROR.CARD_CONTENT_FAILED === 230099', () => {
|
|
189
|
-
expect(CARD_ERROR.CARD_CONTENT_FAILED).toBe(230099)
|
|
190
|
-
})
|
|
191
|
-
it('CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT === 11310', () => {
|
|
192
|
-
expect(CARD_CONTENT_SUB_ERROR.ELEMENT_LIMIT).toBe(11310)
|
|
193
|
-
})
|
|
194
|
-
})
|