@wu529778790/open-im 1.8.1 → 1.8.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/access/access-control.js +1 -1
- package/dist/adapters/claude-sdk-adapter.js +71 -58
- package/dist/cli.js +5 -2
- package/dist/commands/handler.d.ts +0 -1
- package/dist/commands/handler.js +6 -20
- package/dist/config-web.js +24 -9
- package/dist/dingtalk/event-handler.js +7 -3
- package/dist/dingtalk/message-sender.js +13 -0
- package/dist/feishu/event-handler.js +39 -19
- package/dist/index.js +9 -0
- package/dist/manager-control.js +7 -0
- package/dist/qq/client.js +6 -1
- package/dist/qq/event-handler.js +5 -2
- package/dist/qq/message-sender.js +11 -0
- package/dist/service-control.js +4 -0
- package/dist/session/session-manager.js +11 -1
- package/dist/shared/chat-user-map.js +11 -0
- package/dist/shared/media-storage.js +27 -0
- package/dist/telegram/client.js +3 -1
- package/dist/telegram/event-handler.js +41 -9
- package/dist/telegram/message-sender.js +13 -0
- package/dist/wechat/auth/qclaw-api.js +1 -1
- package/dist/wechat/client.js +34 -0
- package/dist/wechat/event-handler.js +7 -4
- package/dist/wework/event-handler.js +28 -4
- package/dist/wework/message-sender.js +53 -21
- package/package.json +2 -2
|
@@ -8,7 +8,7 @@ export class AccessControl {
|
|
|
8
8
|
}
|
|
9
9
|
isAllowed(userId) {
|
|
10
10
|
if (this.allowedUserIds.size === 0) {
|
|
11
|
-
log.
|
|
11
|
+
log.warn(`Allowing user ${userId} — no whitelist configured. Set allowedUserIds to restrict access.`);
|
|
12
12
|
return true;
|
|
13
13
|
}
|
|
14
14
|
const allowed = this.allowedUserIds.has(userId);
|
|
@@ -16,6 +16,21 @@ const log = createLogger('ClaudeSDK');
|
|
|
16
16
|
const activeSessions = new Map();
|
|
17
17
|
// 存储正在进行的流式迭代器,用于中断
|
|
18
18
|
const activeStreams = new Set();
|
|
19
|
+
// Mutex to serialize process.chdir() calls across concurrent users
|
|
20
|
+
let chdirMutex = Promise.resolve();
|
|
21
|
+
function withChdirMutex(fn) {
|
|
22
|
+
const previous = chdirMutex;
|
|
23
|
+
let resolve;
|
|
24
|
+
chdirMutex = new Promise((r) => { resolve = r; });
|
|
25
|
+
return previous.then(() => {
|
|
26
|
+
try {
|
|
27
|
+
return fn();
|
|
28
|
+
}
|
|
29
|
+
finally {
|
|
30
|
+
resolve();
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
}
|
|
19
34
|
function isStreamEvent(msg) {
|
|
20
35
|
return msg.type === 'stream_event';
|
|
21
36
|
}
|
|
@@ -37,39 +52,51 @@ function isResult(msg) {
|
|
|
37
52
|
* @param permissionMode 权限模式
|
|
38
53
|
* @returns SDKSession 对象和实际的 sessionId
|
|
39
54
|
*/
|
|
40
|
-
async function getOrCreateSession(sessionId,
|
|
41
|
-
model, permissionMode) {
|
|
55
|
+
async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
|
|
42
56
|
const resolvedModel = model?.trim() || 'claude-opus-4-5';
|
|
43
57
|
const sessionOptions = {
|
|
44
58
|
model: resolvedModel,
|
|
45
59
|
permissionMode,
|
|
46
|
-
// 可以添加其他选项,如 hooks, allowedTools 等
|
|
47
60
|
};
|
|
48
61
|
const baseUrl = process.env.ANTHROPIC_BASE_URL ?? '(default)';
|
|
49
|
-
log.info(`[V2] getOrCreateSession model param=${String(model ?? '')} resolved=${resolvedModel} baseUrl=${baseUrl}`);
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
62
|
+
log.info(`[V2] getOrCreateSession model param=${String(model ?? '')} resolved=${resolvedModel} baseUrl=${baseUrl} workDir=${workDir}`);
|
|
63
|
+
// Use mutex to serialize process.chdir() calls across concurrent users
|
|
64
|
+
return withChdirMutex(() => {
|
|
65
|
+
let session;
|
|
66
|
+
const originalCwd = process.cwd();
|
|
53
67
|
try {
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
68
|
+
if (workDir && workDir !== originalCwd) {
|
|
69
|
+
process.chdir(workDir);
|
|
70
|
+
}
|
|
71
|
+
if (sessionId) {
|
|
72
|
+
// 尝试恢复已有会话
|
|
73
|
+
try {
|
|
74
|
+
log.info(`Attempting to resume session: ${sessionId}`);
|
|
75
|
+
session = unstable_v2_resumeSession(sessionId, sessionOptions);
|
|
76
|
+
activeSessions.set(sessionId, session);
|
|
77
|
+
log.info(`Successfully resumed session: ${sessionId}`);
|
|
78
|
+
return { session, sessionId };
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
log.warn(`Failed to resume session ${sessionId}, creating new one: ${err}`);
|
|
82
|
+
// 恢复失败,创建新会话
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// 创建新会话
|
|
86
|
+
session = unstable_v2_createSession(sessionOptions);
|
|
87
|
+
// 新会话的 sessionId 需要从第一个消息中获取
|
|
88
|
+
// 暂时返回 undefined,稍后在 init 消息中获取
|
|
89
|
+
const tempId = `pending-${Date.now()}`;
|
|
90
|
+
activeSessions.set(tempId, session);
|
|
91
|
+
log.info(`Created new session (tempId: ${tempId})`);
|
|
92
|
+
return { session, sessionId: tempId };
|
|
59
93
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
finally {
|
|
95
|
+
if (workDir && workDir !== originalCwd) {
|
|
96
|
+
process.chdir(originalCwd);
|
|
97
|
+
}
|
|
63
98
|
}
|
|
64
|
-
}
|
|
65
|
-
// 创建新会话
|
|
66
|
-
session = unstable_v2_createSession(sessionOptions);
|
|
67
|
-
// 新会话的 sessionId 需要从第一个消息中获取
|
|
68
|
-
// 暂时返回 undefined,稍后在 init 消息中获取
|
|
69
|
-
const tempId = `pending-${Date.now()}`;
|
|
70
|
-
activeSessions.set(tempId, session);
|
|
71
|
-
log.info(`Created new session (tempId: ${tempId})`);
|
|
72
|
-
return { session, sessionId: tempId };
|
|
99
|
+
});
|
|
73
100
|
}
|
|
74
101
|
export class ClaudeSDKAdapter {
|
|
75
102
|
toolId = 'claude-sdk';
|
|
@@ -105,14 +132,6 @@ export class ClaudeSDKAdapter {
|
|
|
105
132
|
let actualSessionId;
|
|
106
133
|
let pendingTempId; // 记录临时 ID,用于 abort 时清理
|
|
107
134
|
let runSettled = false;
|
|
108
|
-
let timeoutId = null;
|
|
109
|
-
const timeoutMs = options?.timeoutMs ?? 600_000;
|
|
110
|
-
const clearRunTimeout = () => {
|
|
111
|
-
if (timeoutId !== null) {
|
|
112
|
-
clearTimeout(timeoutId);
|
|
113
|
-
timeoutId = null;
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
135
|
const permissionMode = options?.skipPermissions
|
|
117
136
|
? 'bypassPermissions'
|
|
118
137
|
: options?.permissionMode === 'acceptEdits'
|
|
@@ -121,15 +140,6 @@ export class ClaudeSDKAdapter {
|
|
|
121
140
|
? 'plan'
|
|
122
141
|
: 'default';
|
|
123
142
|
const runSession = async () => {
|
|
124
|
-
timeoutId = setTimeout(() => {
|
|
125
|
-
if (runSettled)
|
|
126
|
-
return;
|
|
127
|
-
runSettled = true;
|
|
128
|
-
clearRunTimeout();
|
|
129
|
-
log.warn(`[ClaudeSDK] Request timeout after ${timeoutMs}ms`);
|
|
130
|
-
abortController.abort();
|
|
131
|
-
callbacks.onError(`请求超时(${Math.round(timeoutMs / 1000)}s),请重试或缩短问题。`);
|
|
132
|
-
}, timeoutMs);
|
|
133
143
|
try {
|
|
134
144
|
// 检查环境变量
|
|
135
145
|
const hasApiKey = !!process.env.ANTHROPIC_API_KEY;
|
|
@@ -209,7 +219,6 @@ export class ClaudeSDKAdapter {
|
|
|
209
219
|
// 检查会话错误
|
|
210
220
|
if (!success) {
|
|
211
221
|
runSettled = true;
|
|
212
|
-
clearRunTimeout();
|
|
213
222
|
const noConvErr = errs.find((e) => e.includes('No conversation found') || e.includes('session not found'));
|
|
214
223
|
if (noConvErr) {
|
|
215
224
|
log.warn(`Session ${actualSessionId} not found, may need to create new one`);
|
|
@@ -237,25 +246,31 @@ export class ClaudeSDKAdapter {
|
|
|
237
246
|
result.result = accumulated;
|
|
238
247
|
}
|
|
239
248
|
runSettled = true;
|
|
240
|
-
clearRunTimeout();
|
|
241
249
|
callbacks.onComplete(result);
|
|
242
250
|
return;
|
|
243
251
|
}
|
|
244
252
|
}
|
|
245
253
|
// 如果流正常结束但没有收到 result 消息
|
|
246
|
-
if (!streamClosed
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
254
|
+
if (!streamClosed) {
|
|
255
|
+
if (accumulated) {
|
|
256
|
+
log.info('Stream ended without result message, using accumulated text');
|
|
257
|
+
runSettled = true;
|
|
258
|
+
callbacks.onComplete({
|
|
259
|
+
success: true,
|
|
260
|
+
result: accumulated,
|
|
261
|
+
accumulated,
|
|
262
|
+
cost: 0,
|
|
263
|
+
durationMs: 0,
|
|
264
|
+
numTurns: 1,
|
|
265
|
+
toolStats,
|
|
266
|
+
});
|
|
267
|
+
}
|
|
268
|
+
else {
|
|
269
|
+
// 流结束但无 result 也无 accumulated:必须触发回调,否则 Promise 永远挂起
|
|
270
|
+
log.warn('Stream ended with no result and no accumulated text, calling onError to prevent stuck state');
|
|
271
|
+
runSettled = true;
|
|
272
|
+
callbacks.onError('AI 响应异常结束(无输出),请重试');
|
|
273
|
+
}
|
|
259
274
|
}
|
|
260
275
|
}
|
|
261
276
|
finally {
|
|
@@ -266,7 +281,6 @@ export class ClaudeSDKAdapter {
|
|
|
266
281
|
catch (err) {
|
|
267
282
|
if (abortController.signal.aborted) {
|
|
268
283
|
log.info('Session run aborted');
|
|
269
|
-
clearRunTimeout();
|
|
270
284
|
// 清理 pending tempId(abort 可能在 init 消息之前发生)
|
|
271
285
|
const idToClean = actualSessionId ?? pendingTempId;
|
|
272
286
|
if (idToClean?.startsWith('pending-')) {
|
|
@@ -276,7 +290,6 @@ export class ClaudeSDKAdapter {
|
|
|
276
290
|
return;
|
|
277
291
|
}
|
|
278
292
|
runSettled = true;
|
|
279
|
-
clearRunTimeout();
|
|
280
293
|
const errorObj = err;
|
|
281
294
|
const msg = errorObj.message || String(err);
|
|
282
295
|
log.error(`Claude SDK V2 error: ${msg}`);
|
package/dist/cli.js
CHANGED
|
@@ -50,7 +50,7 @@ async function cmdStart() {
|
|
|
50
50
|
console.log("\nopen-im is already running in the background.");
|
|
51
51
|
console.log(` pid: ${status.pid}`);
|
|
52
52
|
console.log(` config page: ${getWebConfigUrl()}`);
|
|
53
|
-
|
|
53
|
+
process.exit(0);
|
|
54
54
|
}
|
|
55
55
|
if (!(await ensureConfigured("start"))) {
|
|
56
56
|
process.exit(1);
|
|
@@ -67,17 +67,19 @@ async function cmdStart() {
|
|
|
67
67
|
console.log(" A one-time login URL (with login_token) has been printed by the config-web server logger.");
|
|
68
68
|
console.log(" Please use that URL (replacing 127.0.0.1 with your server IP/hostname) for the first login.");
|
|
69
69
|
}
|
|
70
|
+
process.exit(0);
|
|
70
71
|
}
|
|
71
72
|
async function cmdStop() {
|
|
72
73
|
const status = getManagerStatus();
|
|
73
74
|
if (!status.pid) {
|
|
74
75
|
console.log("open-im is not running in the background.");
|
|
75
|
-
|
|
76
|
+
process.exit(0);
|
|
76
77
|
}
|
|
77
78
|
await stopBackgroundService();
|
|
78
79
|
const result = await stopManagerProcess();
|
|
79
80
|
console.log("\nopen-im stopped.");
|
|
80
81
|
console.log(` pid: ${result.pid}`);
|
|
82
|
+
process.exit(0);
|
|
81
83
|
}
|
|
82
84
|
async function cmdRestart() {
|
|
83
85
|
const status = getManagerStatus();
|
|
@@ -98,6 +100,7 @@ async function cmdRestart() {
|
|
|
98
100
|
console.log("\nopen-im restarted in the background.");
|
|
99
101
|
console.log(` pid: ${child.pid}`);
|
|
100
102
|
console.log(` config page: ${getWebConfigUrl()}`);
|
|
103
|
+
process.exit(0);
|
|
101
104
|
}
|
|
102
105
|
async function cmdInit() {
|
|
103
106
|
console.log("\nopen-im CLI setup\n");
|
|
@@ -19,7 +19,6 @@ export declare class CommandHandler {
|
|
|
19
19
|
private deps;
|
|
20
20
|
constructor(deps: CommandHandlerDeps);
|
|
21
21
|
dispatch(text: string, chatId: string, userId: string, platform: 'dingtalk' | 'feishu' | 'qq' | 'telegram' | 'wechat' | 'wework' | 'workbuddy', handleClaudeRequest: ClaudeRequestHandler): Promise<boolean>;
|
|
22
|
-
private getClearHistoryHint;
|
|
23
22
|
private handleHelp;
|
|
24
23
|
private handleNew;
|
|
25
24
|
private handlePwd;
|
package/dist/commands/handler.js
CHANGED
|
@@ -16,9 +16,9 @@ export class CommandHandler {
|
|
|
16
16
|
return true;
|
|
17
17
|
}
|
|
18
18
|
if (t === '/help')
|
|
19
|
-
return this.handleHelp(chatId
|
|
19
|
+
return this.handleHelp(chatId);
|
|
20
20
|
if (t === '/new')
|
|
21
|
-
return this.handleNew(chatId, userId
|
|
21
|
+
return this.handleNew(chatId, userId);
|
|
22
22
|
if (t === '/pwd')
|
|
23
23
|
return this.handlePwd(chatId, userId);
|
|
24
24
|
if (t === '/status')
|
|
@@ -33,18 +33,7 @@ export class CommandHandler {
|
|
|
33
33
|
}
|
|
34
34
|
return false;
|
|
35
35
|
}
|
|
36
|
-
|
|
37
|
-
return platform === 'feishu'
|
|
38
|
-
? '💡 提示:如需清除本对话的历史消息,请点击飞书聊天右上角「...」→ 清除聊天记录'
|
|
39
|
-
: platform === 'wechat'
|
|
40
|
-
? '💡 提示:如需清除本对话的历史消息,请清除聊天记录'
|
|
41
|
-
: platform === 'dingtalk'
|
|
42
|
-
? '💡 提示:如需清除本对话的历史消息,请在钉钉中清空聊天记录'
|
|
43
|
-
: platform === 'workbuddy'
|
|
44
|
-
? '💡 提示:如需清除本对话的历史消息,请清除聊天记录'
|
|
45
|
-
: '💡 提示:如需清除本对话的历史消息,请点击 Telegram 聊天右上角 ⋮ → 清除历史';
|
|
46
|
-
}
|
|
47
|
-
async handleHelp(chatId, platform) {
|
|
36
|
+
async handleHelp(chatId) {
|
|
48
37
|
const help = [
|
|
49
38
|
'📋 可用命令:',
|
|
50
39
|
'',
|
|
@@ -53,16 +42,14 @@ export class CommandHandler {
|
|
|
53
42
|
'/status - 显示状态',
|
|
54
43
|
'/cd <路径> - 切换工作目录',
|
|
55
44
|
'/pwd - 当前工作目录',
|
|
56
|
-
'',
|
|
57
|
-
this.getClearHistoryHint(platform),
|
|
58
45
|
].join('\n');
|
|
59
46
|
await this.deps.sender.sendTextReply(chatId, help);
|
|
60
47
|
return true;
|
|
61
48
|
}
|
|
62
|
-
async handleNew(chatId, userId
|
|
49
|
+
async handleNew(chatId, userId) {
|
|
63
50
|
const ok = this.deps.sessionManager.newSession(userId);
|
|
64
51
|
await this.deps.sender.sendTextReply(chatId, ok
|
|
65
|
-
?
|
|
52
|
+
? '✅ AI 会话已重置,下一条消息将使用全新上下文。'
|
|
66
53
|
: '当前没有活动会话。');
|
|
67
54
|
return true;
|
|
68
55
|
}
|
|
@@ -103,8 +90,7 @@ export class CommandHandler {
|
|
|
103
90
|
try {
|
|
104
91
|
const resolved = await this.deps.sessionManager.setWorkDir(userId, dir);
|
|
105
92
|
await this.deps.sender.sendTextReply(chatId, `📁 工作目录已切换到: ${escapePathForMarkdown(resolved)}\n\n` +
|
|
106
|
-
`🔄 AI
|
|
107
|
-
this.getClearHistoryHint(platform));
|
|
93
|
+
`🔄 AI 会话已重置,下一条消息将使用全新上下文。`);
|
|
108
94
|
}
|
|
109
95
|
catch (err) {
|
|
110
96
|
await this.deps.sender.sendTextReply(chatId, err instanceof Error ? err.message : String(err));
|
package/dist/config-web.js
CHANGED
|
@@ -180,10 +180,20 @@ function clean(value) {
|
|
|
180
180
|
const trimmed = value.trim();
|
|
181
181
|
return trimmed ? trimmed : undefined;
|
|
182
182
|
}
|
|
183
|
+
const MAX_REQUEST_BODY_BYTES = 1 * 1024 * 1024; // 1 MB
|
|
183
184
|
function readJson(request) {
|
|
184
185
|
return new Promise((resolve, reject) => {
|
|
185
186
|
const chunks = [];
|
|
186
|
-
|
|
187
|
+
let totalBytes = 0;
|
|
188
|
+
request.on("data", (chunk) => {
|
|
189
|
+
totalBytes += chunk.length;
|
|
190
|
+
if (totalBytes > MAX_REQUEST_BODY_BYTES) {
|
|
191
|
+
reject(new Error("Request body too large (max 1 MB)"));
|
|
192
|
+
request.destroy();
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
chunks.push(Buffer.from(chunk));
|
|
196
|
+
});
|
|
187
197
|
request.on("end", () => {
|
|
188
198
|
try {
|
|
189
199
|
const raw = Buffer.concat(chunks).toString("utf-8");
|
|
@@ -200,6 +210,11 @@ function json(response, statusCode, body) {
|
|
|
200
210
|
response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8" });
|
|
201
211
|
response.end(JSON.stringify(body));
|
|
202
212
|
}
|
|
213
|
+
function maskSecret(value) {
|
|
214
|
+
if (!value || value.length <= 4)
|
|
215
|
+
return value ? "****" : "";
|
|
216
|
+
return value.slice(0, 2) + "****" + value.slice(-2);
|
|
217
|
+
}
|
|
203
218
|
function buildInitialPayload(file) {
|
|
204
219
|
// Load Claude settings from ~/.claude/settings.json
|
|
205
220
|
const claudeEnv = loadClaudeSettingsEnv();
|
|
@@ -208,7 +223,7 @@ function buildInitialPayload(file) {
|
|
|
208
223
|
telegram: {
|
|
209
224
|
enabled: file.platforms?.telegram?.enabled ?? Boolean(file.platforms?.telegram?.botToken),
|
|
210
225
|
aiCommand: file.platforms?.telegram?.aiCommand ?? "",
|
|
211
|
-
botToken: file.platforms?.telegram?.botToken
|
|
226
|
+
botToken: maskSecret(file.platforms?.telegram?.botToken),
|
|
212
227
|
proxy: file.platforms?.telegram?.proxy ?? "",
|
|
213
228
|
allowedUserIds: (file.platforms?.telegram?.allowedUserIds ?? []).join(", "),
|
|
214
229
|
},
|
|
@@ -216,36 +231,36 @@ function buildInitialPayload(file) {
|
|
|
216
231
|
enabled: file.platforms?.feishu?.enabled ?? Boolean(file.platforms?.feishu?.appId && file.platforms?.feishu?.appSecret),
|
|
217
232
|
aiCommand: file.platforms?.feishu?.aiCommand ?? "",
|
|
218
233
|
appId: file.platforms?.feishu?.appId ?? "",
|
|
219
|
-
appSecret: file.platforms?.feishu?.appSecret
|
|
234
|
+
appSecret: maskSecret(file.platforms?.feishu?.appSecret),
|
|
220
235
|
allowedUserIds: (file.platforms?.feishu?.allowedUserIds ?? []).join(", "),
|
|
221
236
|
},
|
|
222
237
|
qq: {
|
|
223
238
|
enabled: file.platforms?.qq?.enabled ?? Boolean(file.platforms?.qq?.appId && file.platforms?.qq?.secret),
|
|
224
239
|
aiCommand: file.platforms?.qq?.aiCommand ?? "",
|
|
225
240
|
appId: file.platforms?.qq?.appId ?? "",
|
|
226
|
-
secret: file.platforms?.qq?.secret
|
|
241
|
+
secret: maskSecret(file.platforms?.qq?.secret),
|
|
227
242
|
allowedUserIds: (file.platforms?.qq?.allowedUserIds ?? []).join(", "),
|
|
228
243
|
},
|
|
229
244
|
wework: {
|
|
230
245
|
enabled: file.platforms?.wework?.enabled ?? Boolean(file.platforms?.wework?.corpId && file.platforms?.wework?.secret),
|
|
231
246
|
aiCommand: file.platforms?.wework?.aiCommand ?? "",
|
|
232
247
|
corpId: file.platforms?.wework?.corpId ?? "",
|
|
233
|
-
secret: file.platforms?.wework?.secret
|
|
248
|
+
secret: maskSecret(file.platforms?.wework?.secret),
|
|
234
249
|
allowedUserIds: (file.platforms?.wework?.allowedUserIds ?? []).join(", "),
|
|
235
250
|
},
|
|
236
251
|
dingtalk: {
|
|
237
252
|
enabled: file.platforms?.dingtalk?.enabled ?? Boolean(file.platforms?.dingtalk?.clientId && file.platforms?.dingtalk?.clientSecret),
|
|
238
253
|
aiCommand: file.platforms?.dingtalk?.aiCommand ?? "",
|
|
239
254
|
clientId: file.platforms?.dingtalk?.clientId ?? "",
|
|
240
|
-
clientSecret: file.platforms?.dingtalk?.clientSecret
|
|
255
|
+
clientSecret: maskSecret(file.platforms?.dingtalk?.clientSecret),
|
|
241
256
|
cardTemplateId: file.platforms?.dingtalk?.cardTemplateId ?? "",
|
|
242
257
|
allowedUserIds: (file.platforms?.dingtalk?.allowedUserIds ?? []).join(", "),
|
|
243
258
|
},
|
|
244
259
|
workbuddy: {
|
|
245
260
|
enabled: file.platforms?.workbuddy?.enabled ?? Boolean(file.platforms?.workbuddy?.accessToken && file.platforms?.workbuddy?.refreshToken && file.platforms?.workbuddy?.userId),
|
|
246
261
|
aiCommand: file.platforms?.workbuddy?.aiCommand ?? "",
|
|
247
|
-
accessToken: file.platforms?.workbuddy?.accessToken
|
|
248
|
-
refreshToken: file.platforms?.workbuddy?.refreshToken
|
|
262
|
+
accessToken: maskSecret(file.platforms?.workbuddy?.accessToken),
|
|
263
|
+
refreshToken: maskSecret(file.platforms?.workbuddy?.refreshToken),
|
|
249
264
|
userId: file.platforms?.workbuddy?.userId ?? "",
|
|
250
265
|
baseUrl: file.platforms?.workbuddy?.baseUrl ?? "",
|
|
251
266
|
allowedUserIds: (file.platforms?.workbuddy?.allowedUserIds ?? []).join(", "),
|
|
@@ -258,7 +273,7 @@ function buildInitialPayload(file) {
|
|
|
258
273
|
claudeConfigPath: process.platform === 'win32'
|
|
259
274
|
? getClaudeConfigHome() + "\\.claude\\settings.json"
|
|
260
275
|
: getClaudeConfigHome() + "/.claude/settings.json",
|
|
261
|
-
claudeAuthToken: claudeEnv.ANTHROPIC_AUTH_TOKEN
|
|
276
|
+
claudeAuthToken: maskSecret(claudeEnv.ANTHROPIC_AUTH_TOKEN),
|
|
262
277
|
claudeBaseUrl: claudeEnv.ANTHROPIC_BASE_URL ?? "",
|
|
263
278
|
claudeModel: claudeEnv.ANTHROPIC_MODEL ?? "",
|
|
264
279
|
claudeProxy: file.tools?.claude?.proxy ?? "",
|
|
@@ -99,7 +99,8 @@ async function buildMediaPrompt(message, kind, robotCodeFallback) {
|
|
|
99
99
|
fallbackExtension: kind === 'image' ? 'jpg' : 'bin',
|
|
100
100
|
});
|
|
101
101
|
}
|
|
102
|
-
catch {
|
|
102
|
+
catch (err) {
|
|
103
|
+
log.warn('Failed to download DingTalk media from URL:', err);
|
|
103
104
|
localPath = undefined;
|
|
104
105
|
}
|
|
105
106
|
}
|
|
@@ -118,7 +119,8 @@ async function buildMediaPrompt(message, kind, robotCodeFallback) {
|
|
|
118
119
|
localPath = await saveBufferMedia(downloaded.buffer, extension, basenameHint);
|
|
119
120
|
}
|
|
120
121
|
}
|
|
121
|
-
catch {
|
|
122
|
+
catch (err) {
|
|
123
|
+
log.warn('Failed to download DingTalk media via robotCode:', err);
|
|
122
124
|
localPath = undefined;
|
|
123
125
|
}
|
|
124
126
|
}
|
|
@@ -194,7 +196,9 @@ export function setupDingTalkHandlers(config, sessionManager) {
|
|
|
194
196
|
try {
|
|
195
197
|
await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
|
|
196
198
|
}
|
|
197
|
-
catch {
|
|
199
|
+
catch (err) {
|
|
200
|
+
log.warn('Failed to send startup error reply:', err);
|
|
201
|
+
}
|
|
198
202
|
return;
|
|
199
203
|
}
|
|
200
204
|
const stopTyping = startTypingLoop(chatId);
|
|
@@ -24,6 +24,19 @@ const FLOW_STATUS = {
|
|
|
24
24
|
};
|
|
25
25
|
let senderSettings = {};
|
|
26
26
|
const streamStates = new Map();
|
|
27
|
+
// Periodic cleanup of orphaned stream states (max 30 minutes)
|
|
28
|
+
const STREAM_MAX_AGE_MS = 30 * 60 * 1000;
|
|
29
|
+
setInterval(() => {
|
|
30
|
+
const now = Date.now();
|
|
31
|
+
for (const [id, state] of streamStates) {
|
|
32
|
+
// streamStates in DingTalk don't have createdAt, clean up by size
|
|
33
|
+
if (streamStates.size > 50) {
|
|
34
|
+
streamStates.delete(id);
|
|
35
|
+
log.info(`Cleaned up old DingTalk stream state: ${id}`);
|
|
36
|
+
break; // Clean one at a time to avoid blocking
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}, STREAM_MAX_AGE_MS);
|
|
27
40
|
function generateMessageId() {
|
|
28
41
|
return `${Date.now()}-${randomBytes(6).toString('hex')}`;
|
|
29
42
|
}
|
|
@@ -88,7 +88,9 @@ async function sendPermissionFallback(chatId, guide) {
|
|
|
88
88
|
await sendTextReply(chatId, guide);
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
|
-
catch {
|
|
91
|
+
catch (err) {
|
|
92
|
+
log.warn('Card-based reply failed, falling back to plain text:', err);
|
|
93
|
+
}
|
|
92
94
|
// 2. 降级为纯文本消息
|
|
93
95
|
try {
|
|
94
96
|
const client = (await import('./client.js')).getClient();
|
|
@@ -103,7 +105,9 @@ async function sendPermissionFallback(chatId, guide) {
|
|
|
103
105
|
});
|
|
104
106
|
return;
|
|
105
107
|
}
|
|
106
|
-
catch {
|
|
108
|
+
catch (err) {
|
|
109
|
+
log.warn('Plain text reply also failed:', err);
|
|
110
|
+
}
|
|
107
111
|
log.error('All fallback methods failed to send permission guide');
|
|
108
112
|
}
|
|
109
113
|
async function downloadFeishuMessageResource(client, messageId, fileKey, type, options) {
|
|
@@ -146,23 +150,38 @@ export function setupFeishuHandlers(config, sessionManager) {
|
|
|
146
150
|
const toolId = aiCommand;
|
|
147
151
|
// 使用 CardKit 打字机效果(80ms 节流,约 12 次/秒,比 patch 5 QPS 更流畅)
|
|
148
152
|
let cardHandle;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
// 检测是否为飞书权限不足
|
|
155
|
-
if (isPermissionError(err)) {
|
|
156
|
-
const guide = buildPermissionGuideMessage(err);
|
|
157
|
-
await sendPermissionFallback(chatId, guide).catch(() => { });
|
|
153
|
+
const MAX_SEND_RETRIES = 3;
|
|
154
|
+
for (let attempt = 1; attempt <= MAX_SEND_RETRIES; attempt++) {
|
|
155
|
+
try {
|
|
156
|
+
cardHandle = await sendThinkingCard(chatId, toolId);
|
|
157
|
+
break;
|
|
158
158
|
}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
159
|
+
catch (err) {
|
|
160
|
+
const isRetryable = err && typeof err === 'object' && 'code' in err &&
|
|
161
|
+
(err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET' || err.code === 'ECONNREFUSED');
|
|
162
|
+
if (isRetryable && attempt < MAX_SEND_RETRIES) {
|
|
163
|
+
log.warn(`sendThinkingCard attempt ${attempt}/${MAX_SEND_RETRIES} failed (${err.code}), retrying...`);
|
|
164
|
+
await new Promise((r) => setTimeout(r, 1000 * attempt));
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
log.error(`Failed to send thinking card after ${attempt} attempts:`, err);
|
|
168
|
+
// 检测是否为飞书权限不足
|
|
169
|
+
if (isPermissionError(err)) {
|
|
170
|
+
const guide = buildPermissionGuideMessage(err);
|
|
171
|
+
await sendPermissionFallback(chatId, guide).catch((err) => {
|
|
172
|
+
log.warn('Permission fallback send failed:', err);
|
|
173
|
+
});
|
|
162
174
|
}
|
|
163
|
-
|
|
175
|
+
else {
|
|
176
|
+
try {
|
|
177
|
+
await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
|
|
178
|
+
}
|
|
179
|
+
catch (err) {
|
|
180
|
+
log.warn('Failed to send startup error reply:', err);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
return;
|
|
164
184
|
}
|
|
165
|
-
return;
|
|
166
185
|
}
|
|
167
186
|
const { messageId: msgId, cardId } = cardHandle;
|
|
168
187
|
const stopTyping = startTypingLoop(chatId);
|
|
@@ -219,7 +238,8 @@ export function setupFeishuHandlers(config, sessionManager) {
|
|
|
219
238
|
try {
|
|
220
239
|
obj = JSON.parse(raw);
|
|
221
240
|
}
|
|
222
|
-
catch {
|
|
241
|
+
catch (err) {
|
|
242
|
+
log.debug('Failed to parse action value as JSON:', err);
|
|
223
243
|
return null;
|
|
224
244
|
}
|
|
225
245
|
}
|
|
@@ -272,8 +292,8 @@ export function setupFeishuHandlers(config, sessionManager) {
|
|
|
272
292
|
}
|
|
273
293
|
actionData = parsed;
|
|
274
294
|
}
|
|
275
|
-
catch {
|
|
276
|
-
|
|
295
|
+
catch (err) {
|
|
296
|
+
log.debug('Failed to parse card action data:', err);
|
|
277
297
|
}
|
|
278
298
|
if (actionData?.action === 'stop' && actionData.card_id) {
|
|
279
299
|
const cardId = actionData.card_id;
|
package/dist/index.js
CHANGED
|
@@ -306,6 +306,15 @@ export async function main() {
|
|
|
306
306
|
};
|
|
307
307
|
process.on("SIGINT", () => shutdown().catch(() => process.exit(1)));
|
|
308
308
|
process.on("SIGTERM", () => shutdown().catch(() => process.exit(1)));
|
|
309
|
+
// Global error handlers to prevent unhandled crashes
|
|
310
|
+
process.on("unhandledRejection", (reason) => {
|
|
311
|
+
log.error("Unhandled Promise rejection:", reason);
|
|
312
|
+
});
|
|
313
|
+
process.on("uncaughtException", (err) => {
|
|
314
|
+
log.error("Uncaught exception (process will exit):", err);
|
|
315
|
+
closeLogger();
|
|
316
|
+
process.exit(1);
|
|
317
|
+
});
|
|
309
318
|
}
|
|
310
319
|
const isEntry = process.argv[1]?.replace(/\\/g, "/").endsWith("/index.js") ||
|
|
311
320
|
process.argv[1]?.replace(/\\/g, "/").endsWith("/index.ts");
|
package/dist/manager-control.js
CHANGED
|
@@ -6,6 +6,10 @@ import { APP_HOME } from "./constants.js";
|
|
|
6
6
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
7
|
const PID_FILE = join(APP_HOME, "open-im.pid");
|
|
8
8
|
const READY_FILE = join(APP_HOME, "open-im.ready");
|
|
9
|
+
function logError(prefix, err) {
|
|
10
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
11
|
+
process.stderr.write(`[manager-control] ${prefix} ${msg}\n`);
|
|
12
|
+
}
|
|
9
13
|
function getManagerEntry() {
|
|
10
14
|
const extension = extname(fileURLToPath(import.meta.url));
|
|
11
15
|
if (extension === ".ts") {
|
|
@@ -106,6 +110,9 @@ export async function startManagerProcess(cwd) {
|
|
|
106
110
|
env: process.env,
|
|
107
111
|
windowsHide: process.platform === "win32",
|
|
108
112
|
});
|
|
113
|
+
child.on("error", (err) => {
|
|
114
|
+
logError("Manager process spawn failed:", err);
|
|
115
|
+
});
|
|
109
116
|
child.unref();
|
|
110
117
|
if (!child.pid) {
|
|
111
118
|
throw new Error("Failed to start manager process.");
|
package/dist/qq/client.js
CHANGED
|
@@ -144,7 +144,12 @@ function startHeartbeat(intervalMs) {
|
|
|
144
144
|
heartbeatTimer = setInterval(() => {
|
|
145
145
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
146
146
|
return;
|
|
147
|
-
|
|
147
|
+
try {
|
|
148
|
+
ws.send(JSON.stringify({ op: 1, d: seq }));
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
log.warn('QQ heartbeat send failed:', err);
|
|
152
|
+
}
|
|
148
153
|
}, intervalMs);
|
|
149
154
|
}
|
|
150
155
|
async function connectWebSocket(config, handler) {
|
package/dist/qq/event-handler.js
CHANGED
|
@@ -62,7 +62,8 @@ async function buildAttachmentPrompt(event) {
|
|
|
62
62
|
: "bin",
|
|
63
63
|
});
|
|
64
64
|
}
|
|
65
|
-
catch {
|
|
65
|
+
catch (err) {
|
|
66
|
+
log.warn('Failed to download QQ media attachment:', err);
|
|
66
67
|
localPath = undefined;
|
|
67
68
|
}
|
|
68
69
|
}
|
|
@@ -153,7 +154,9 @@ export function setupQQHandlers(config, sessionManager) {
|
|
|
153
154
|
try {
|
|
154
155
|
await sendTextReply(chatId, "启动 AI 处理失败,请重试。");
|
|
155
156
|
}
|
|
156
|
-
catch {
|
|
157
|
+
catch (err) {
|
|
158
|
+
log.warn('Failed to send startup error reply:', err);
|
|
159
|
+
}
|
|
157
160
|
return;
|
|
158
161
|
}
|
|
159
162
|
const stopTyping = startTypingLoop();
|
|
@@ -7,6 +7,17 @@ import { buildDirectoryMessage } from "../shared/system-messages.js";
|
|
|
7
7
|
const log = createLogger("QQSender");
|
|
8
8
|
const MAX_QQ_MESSAGE_LENGTH = 1500;
|
|
9
9
|
const pendingReplies = new Map();
|
|
10
|
+
// Periodic cleanup of orphaned pending replies
|
|
11
|
+
const PENDING_MAX_AGE_MS = 10 * 60 * 1000; // 10 minutes
|
|
12
|
+
setInterval(() => {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
for (const [id, state] of pendingReplies) {
|
|
15
|
+
// pendingReplies don't have timestamps, but we can clear old ones based on size
|
|
16
|
+
if (pendingReplies.size > 100) {
|
|
17
|
+
pendingReplies.delete(id);
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}, PENDING_MAX_AGE_MS);
|
|
10
21
|
function parseChatTarget(chatId) {
|
|
11
22
|
if (chatId.startsWith("group:")) {
|
|
12
23
|
return { kind: "group", id: chatId.slice("group:".length) };
|
package/dist/service-control.js
CHANGED
|
@@ -93,6 +93,10 @@ export function startBackgroundService(cwd) {
|
|
|
93
93
|
env: process.env,
|
|
94
94
|
windowsHide: process.platform === "win32",
|
|
95
95
|
});
|
|
96
|
+
child.on("error", (err) => {
|
|
97
|
+
// Spawn failure (ENOENT etc.) — report via stderr since logger may not be initialized
|
|
98
|
+
process.stderr.write(`[service-control] Spawn failed: ${err.message}\n`);
|
|
99
|
+
});
|
|
96
100
|
child.unref();
|
|
97
101
|
if (!child.pid) {
|
|
98
102
|
throw new Error("Failed to start background service.");
|
|
@@ -236,7 +236,17 @@ export class SessionManager {
|
|
|
236
236
|
const resolved = resolveWorkDirInput(baseDir, targetDir);
|
|
237
237
|
if (!existsSync(resolved))
|
|
238
238
|
throw new Error(`目录不存在: \`${resolved}\``);
|
|
239
|
-
|
|
239
|
+
const real = await realpath(resolved);
|
|
240
|
+
// Block access to sensitive system directories
|
|
241
|
+
const blockedPrefixes = process.platform === 'win32'
|
|
242
|
+
? ['C:\\Windows', 'C:\\Program Files', 'C:\\Program Files (x86)', 'C:\\ProgramData']
|
|
243
|
+
: ['/etc', '/proc', '/sys', '/dev', '/boot', '/root', '/sbin', '/usr/sbin'];
|
|
244
|
+
for (const prefix of blockedPrefixes) {
|
|
245
|
+
if (real.toLowerCase().startsWith(prefix.toLowerCase())) {
|
|
246
|
+
throw new Error(`不允许访问系统目录: \`${real}\``);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
return real;
|
|
240
250
|
}
|
|
241
251
|
load(previousDefaultWorkDir) {
|
|
242
252
|
try {
|
|
@@ -4,6 +4,17 @@
|
|
|
4
4
|
*/
|
|
5
5
|
const chatToUser = new Map();
|
|
6
6
|
const chatToPlatform = new Map();
|
|
7
|
+
// Periodic cleanup to prevent unbounded growth (keep last 1000 entries)
|
|
8
|
+
const CHAT_MAP_MAX_SIZE = 1000;
|
|
9
|
+
setInterval(() => {
|
|
10
|
+
if (chatToUser.size > CHAT_MAP_MAX_SIZE) {
|
|
11
|
+
const keysToDelete = [...chatToUser.keys()].slice(0, chatToUser.size - CHAT_MAP_MAX_SIZE);
|
|
12
|
+
for (const key of keysToDelete) {
|
|
13
|
+
chatToUser.delete(key);
|
|
14
|
+
chatToPlatform.delete(key);
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
}, 60 * 60 * 1000); // Check every hour
|
|
7
18
|
export function setChatUser(chatId, userId, platform) {
|
|
8
19
|
chatToUser.set(chatId, userId);
|
|
9
20
|
if (platform)
|
|
@@ -131,6 +131,33 @@ export async function saveBase64Media(base64, extension, basenameHint) {
|
|
|
131
131
|
return saveBufferMedia(Buffer.from(base64, "base64"), extension, basenameHint);
|
|
132
132
|
}
|
|
133
133
|
export async function downloadMediaFromUrl(url, options) {
|
|
134
|
+
// SSRF protection: validate URL before fetching
|
|
135
|
+
const BLOCKED_HOSTS = ['127.0.0.1', 'localhost', '0.0.0.0', '[::1]', '169.254.169.254'];
|
|
136
|
+
let parsedUrl;
|
|
137
|
+
try {
|
|
138
|
+
parsedUrl = new URL(url);
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new Error(`Invalid URL: ${url}`);
|
|
142
|
+
}
|
|
143
|
+
const protocol = parsedUrl.protocol.toLowerCase();
|
|
144
|
+
if (protocol !== 'https:' && protocol !== 'http:') {
|
|
145
|
+
throw new Error(`Unsupported URL protocol: ${protocol}`);
|
|
146
|
+
}
|
|
147
|
+
const hostname = parsedUrl.hostname.toLowerCase();
|
|
148
|
+
for (const blocked of BLOCKED_HOSTS) {
|
|
149
|
+
if (hostname === blocked) {
|
|
150
|
+
throw new Error(`Blocked URL host: ${hostname}`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
// Block link-local and private IPs (e.g., 10.x.x.x, 172.16-31.x.x, 192.168.x.x)
|
|
154
|
+
const ipMatch = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/);
|
|
155
|
+
if (ipMatch) {
|
|
156
|
+
const [, a, b] = ipMatch.map(Number);
|
|
157
|
+
if (a === 10 || (a === 172 && b >= 16 && b <= 31) || (a === 192 && b === 168) || a === 0) {
|
|
158
|
+
throw new Error(`Blocked private/internal IP: ${hostname}`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
134
161
|
await mkdir(IMAGE_DIR, { recursive: true });
|
|
135
162
|
const response = await fetch(url, { signal: AbortSignal.timeout(MEDIA_DOWNLOAD_TIMEOUT_MS) });
|
|
136
163
|
if (!response.ok) {
|
package/dist/telegram/client.js
CHANGED
|
@@ -43,7 +43,9 @@ export async function initTelegram(config, setupHandlers) {
|
|
|
43
43
|
// 不再 exit(1),让其他通道继续运行
|
|
44
44
|
}
|
|
45
45
|
};
|
|
46
|
-
void launchWithRetry()
|
|
46
|
+
void launchWithRetry().catch((err) => {
|
|
47
|
+
log.error("Telegram launchWithRetry failed fatally:", err);
|
|
48
|
+
});
|
|
47
49
|
log.info("Telegram bot launched");
|
|
48
50
|
}
|
|
49
51
|
export function stopTelegram() {
|
|
@@ -110,7 +110,9 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
110
110
|
try {
|
|
111
111
|
await sendTextReply(chatId, "启动 AI 处理失败,请重试。");
|
|
112
112
|
}
|
|
113
|
-
catch {
|
|
113
|
+
catch (err) {
|
|
114
|
+
log.warn('Failed to send startup error reply:', err);
|
|
115
|
+
}
|
|
114
116
|
return;
|
|
115
117
|
}
|
|
116
118
|
const stopTyping = startTypingLoop(chatId);
|
|
@@ -180,7 +182,8 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
180
182
|
throttle.recordSuccess();
|
|
181
183
|
lastUpdateTime = Date.now();
|
|
182
184
|
}
|
|
183
|
-
catch {
|
|
185
|
+
catch (err) {
|
|
186
|
+
log.debug('Stream update failed:', err);
|
|
184
187
|
throttle.recordError();
|
|
185
188
|
}
|
|
186
189
|
finally {
|
|
@@ -194,7 +197,7 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
194
197
|
}
|
|
195
198
|
}
|
|
196
199
|
};
|
|
197
|
-
|
|
200
|
+
const wrapper = (content, toolNote) => {
|
|
198
201
|
if (content.startsWith("💭 **思考中...**")) {
|
|
199
202
|
return;
|
|
200
203
|
}
|
|
@@ -215,6 +218,17 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
215
218
|
performUpdate(content, toolNote);
|
|
216
219
|
}, Math.max(DEBOUNCE_MS, baseDelay));
|
|
217
220
|
};
|
|
221
|
+
// flush 排队的 debounce 更新,防止 sendComplete 时仍有 streaming 更新在排队
|
|
222
|
+
wrapper.flush = async () => {
|
|
223
|
+
if (debounceTimer) {
|
|
224
|
+
clearTimeout(debounceTimer);
|
|
225
|
+
debounceTimer = null;
|
|
226
|
+
}
|
|
227
|
+
while (updateInProgress) {
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 50));
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
return wrapper;
|
|
218
232
|
};
|
|
219
233
|
const streamUpdateWrapper = createStreamUpdateWrapper();
|
|
220
234
|
await runAITask({ config, sessionManager }, {
|
|
@@ -232,12 +246,30 @@ export function setupTelegramHandlers(bot, config, sessionManager) {
|
|
|
232
246
|
},
|
|
233
247
|
sendComplete: async (content, note) => {
|
|
234
248
|
throttle.reset();
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
249
|
+
// 先 flush 排队的 streaming 更新,防止它覆盖后续的 done 消息
|
|
250
|
+
await streamUpdateWrapper.flush?.();
|
|
251
|
+
const maxAttempts = 3;
|
|
252
|
+
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
|
253
|
+
try {
|
|
254
|
+
await sendFinalMessages(chatId, msgId, content, note, toolId);
|
|
255
|
+
return;
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
log.error(`Failed to send complete message (attempt ${attempt}/${maxAttempts}):`, err);
|
|
259
|
+
if (attempt < maxAttempts) {
|
|
260
|
+
await new Promise((r) => setTimeout(r, 2000 * attempt));
|
|
261
|
+
}
|
|
262
|
+
else {
|
|
263
|
+
// 最终失败:尝试发送纯文本作为最后手段
|
|
264
|
+
try {
|
|
265
|
+
await sendTextReply(chatId, `⚠️ 消息更新失败(网络异常),以下是 AI 回复:\n\n${content.slice(0, 4000)}`);
|
|
266
|
+
}
|
|
267
|
+
catch (fallbackErr) {
|
|
268
|
+
log.error("All send attempts failed:", fallbackErr);
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
241
273
|
}
|
|
242
274
|
},
|
|
243
275
|
sendError: async (error) => {
|
|
@@ -9,6 +9,15 @@ import { MAX_TELEGRAM_MESSAGE_LENGTH } from "../constants.js";
|
|
|
9
9
|
import { listDirectories, buildDirectoryKeyboard, } from "../commands/handler.js";
|
|
10
10
|
const log = createLogger("TgSender");
|
|
11
11
|
const lastSentByMsg = new Map();
|
|
12
|
+
// Periodic cleanup of orphaned entries (entries not cleaned by done/error)
|
|
13
|
+
const LAST_SENT_MAX_AGE_MS = 30 * 60 * 1000; // 30 minutes
|
|
14
|
+
setInterval(() => {
|
|
15
|
+
// lastSentByMsg doesn't store timestamps, so clear all entries periodically
|
|
16
|
+
// since they are just dedup cache entries, clearing is safe
|
|
17
|
+
if (lastSentByMsg.size > 0) {
|
|
18
|
+
lastSentByMsg.clear();
|
|
19
|
+
}
|
|
20
|
+
}, LAST_SENT_MAX_AGE_MS);
|
|
12
21
|
const STATUS_ICONS = {
|
|
13
22
|
thinking: "🔵",
|
|
14
23
|
streaming: "🔵",
|
|
@@ -85,6 +94,10 @@ export async function updateMessage(chatId, messageId, content, status, note, to
|
|
|
85
94
|
}
|
|
86
95
|
else {
|
|
87
96
|
log.error("Failed to update message:", err);
|
|
97
|
+
// 对 done/error 状态的更新失败必须 throw,否则消息永远卡在 streaming
|
|
98
|
+
if (status === "done" || status === "error") {
|
|
99
|
+
throw err;
|
|
100
|
+
}
|
|
88
101
|
}
|
|
89
102
|
}
|
|
90
103
|
if (status === "done" || status === "error") {
|
package/dist/wechat/client.js
CHANGED
|
@@ -104,9 +104,28 @@ async function connectWebSocket(config) {
|
|
|
104
104
|
}
|
|
105
105
|
updateState('connecting');
|
|
106
106
|
return new Promise((resolve, reject) => {
|
|
107
|
+
let settled = false;
|
|
108
|
+
// Connection timeout to prevent promise from hanging forever
|
|
109
|
+
const connectionTimeout = setTimeout(() => {
|
|
110
|
+
if (settled)
|
|
111
|
+
return;
|
|
112
|
+
settled = true;
|
|
113
|
+
const err = new Error('WeChat WebSocket connection timeout');
|
|
114
|
+
log.error(err.message);
|
|
115
|
+
updateState('error');
|
|
116
|
+
try {
|
|
117
|
+
ws?.close();
|
|
118
|
+
}
|
|
119
|
+
catch { /* ignore */ }
|
|
120
|
+
reject(err);
|
|
121
|
+
}, 30000);
|
|
107
122
|
try {
|
|
108
123
|
ws = new WebSocket(config.url);
|
|
109
124
|
ws.on('open', () => {
|
|
125
|
+
if (settled)
|
|
126
|
+
return;
|
|
127
|
+
settled = true;
|
|
128
|
+
clearTimeout(connectionTimeout);
|
|
110
129
|
log.info('WeChat WebSocket connected');
|
|
111
130
|
reconnectAttempts = 0;
|
|
112
131
|
updateState('connected');
|
|
@@ -125,18 +144,33 @@ async function connectWebSocket(config) {
|
|
|
125
144
|
}
|
|
126
145
|
});
|
|
127
146
|
ws.on('error', (err) => {
|
|
147
|
+
if (settled) {
|
|
148
|
+
// Late error after connection was established — just log it
|
|
149
|
+
log.error('WeChat WebSocket error (after open):', err);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
settled = true;
|
|
153
|
+
clearTimeout(connectionTimeout);
|
|
128
154
|
log.error('WeChat WebSocket error:', err);
|
|
129
155
|
updateState('error');
|
|
130
156
|
reject(err);
|
|
131
157
|
});
|
|
132
158
|
ws.on('close', () => {
|
|
159
|
+
clearTimeout(connectionTimeout);
|
|
133
160
|
log.info('WeChat WebSocket closed');
|
|
134
161
|
stopHeartbeat();
|
|
135
162
|
updateState('disconnected');
|
|
163
|
+
if (!settled) {
|
|
164
|
+
settled = true;
|
|
165
|
+
reject(new Error('WeChat WebSocket closed before open'));
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
136
168
|
scheduleReconnect(config);
|
|
137
169
|
});
|
|
138
170
|
}
|
|
139
171
|
catch (err) {
|
|
172
|
+
settled = true;
|
|
173
|
+
clearTimeout(connectionTimeout);
|
|
140
174
|
log.error('Error creating WebSocket connection:', err);
|
|
141
175
|
updateState('error');
|
|
142
176
|
reject(err);
|
|
@@ -53,7 +53,8 @@ export function setupWeChatHandlers(config, sessionManager) {
|
|
|
53
53
|
}
|
|
54
54
|
return parsed;
|
|
55
55
|
}
|
|
56
|
-
catch {
|
|
56
|
+
catch (err) {
|
|
57
|
+
log.debug('Failed to parse WeChat incoming message JSON:', err);
|
|
57
58
|
return null;
|
|
58
59
|
}
|
|
59
60
|
}
|
|
@@ -90,8 +91,8 @@ export function setupWeChatHandlers(config, sessionManager) {
|
|
|
90
91
|
text: contextText,
|
|
91
92
|
});
|
|
92
93
|
}
|
|
93
|
-
catch {
|
|
94
|
-
|
|
94
|
+
catch (err) {
|
|
95
|
+
log.warn('Failed to download WeChat media, falling back to metadata-only prompt:', err);
|
|
95
96
|
}
|
|
96
97
|
}
|
|
97
98
|
return buildMediaMetadataPrompt({
|
|
@@ -133,7 +134,9 @@ export function setupWeChatHandlers(config, sessionManager) {
|
|
|
133
134
|
try {
|
|
134
135
|
await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
|
|
135
136
|
}
|
|
136
|
-
catch {
|
|
137
|
+
catch (err) {
|
|
138
|
+
log.warn('Failed to send startup error reply:', err);
|
|
139
|
+
}
|
|
137
140
|
return;
|
|
138
141
|
}
|
|
139
142
|
const stopTyping = startTypingLoop(chatId);
|
|
@@ -21,6 +21,8 @@ import { buildMediaContext } from '../shared/media-context.js';
|
|
|
21
21
|
import { buildErrorNote, buildProgressNote } from '../shared/message-note.js';
|
|
22
22
|
const log = createLogger('WeWorkHandler');
|
|
23
23
|
const WEWORK_MEDIA_TIMEOUT_MS = 60_000;
|
|
24
|
+
// Safety timeout: abort hung tasks before stream expires (5 min TTL → 4.5 min safety)
|
|
25
|
+
const WEWORK_TASK_SAFETY_TIMEOUT_MS = 4.5 * 60 * 1000;
|
|
24
26
|
async function saveWeWorkUrlMedia(payload, fallbackExtension) {
|
|
25
27
|
if (!payload.url) {
|
|
26
28
|
throw new Error("Missing WeWork media URL");
|
|
@@ -119,7 +121,8 @@ export async function buildMediaPrompt(data, kind) {
|
|
|
119
121
|
text: contextText,
|
|
120
122
|
});
|
|
121
123
|
}
|
|
122
|
-
catch {
|
|
124
|
+
catch (err) {
|
|
125
|
+
log.warn('Failed to download WeWork image, falling back to URL reference:', err);
|
|
123
126
|
imageReference = `Remote image URL: ${imagePayload.url}`;
|
|
124
127
|
}
|
|
125
128
|
}
|
|
@@ -144,8 +147,8 @@ export async function buildMediaPrompt(data, kind) {
|
|
|
144
147
|
text: contextText,
|
|
145
148
|
});
|
|
146
149
|
}
|
|
147
|
-
catch {
|
|
148
|
-
|
|
150
|
+
catch (err) {
|
|
151
|
+
log.warn('Failed to download WeWork media, falling back to metadata-only prompt:', err);
|
|
149
152
|
}
|
|
150
153
|
}
|
|
151
154
|
return buildMediaMetadataPrompt({
|
|
@@ -194,11 +197,31 @@ export function setupWeWorkHandlers(config, sessionManager) {
|
|
|
194
197
|
try {
|
|
195
198
|
await sendTextReply(chatId, '启动 AI 处理失败,请重试。', reqId);
|
|
196
199
|
}
|
|
197
|
-
catch {
|
|
200
|
+
catch (err) {
|
|
201
|
+
log.warn('Failed to send startup error reply:', err);
|
|
202
|
+
}
|
|
198
203
|
return;
|
|
199
204
|
}
|
|
200
205
|
const stopTyping = startTypingLoop(chatId);
|
|
201
206
|
const taskKey = `${userId}:${msgId}`;
|
|
207
|
+
// Safety timeout: abort hung tasks before stream expires, unblocking the queue
|
|
208
|
+
let safetyTimer = setTimeout(() => {
|
|
209
|
+
safetyTimer = null;
|
|
210
|
+
const state = runningTasks.get(taskKey);
|
|
211
|
+
if (state) {
|
|
212
|
+
log.warn(`[SAFETY_TIMEOUT] Task ${taskKey} exceeded ${WEWORK_TASK_SAFETY_TIMEOUT_MS}ms, aborting`);
|
|
213
|
+
state.handle.abort();
|
|
214
|
+
runningTasks.delete(taskKey);
|
|
215
|
+
stopTyping();
|
|
216
|
+
sendTextReply(chatId, `AI 处理超时(${Math.round(WEWORK_TASK_SAFETY_TIMEOUT_MS / 1000)}s),已自动取消。请重试。`, reqId).catch(() => { });
|
|
217
|
+
}
|
|
218
|
+
}, WEWORK_TASK_SAFETY_TIMEOUT_MS);
|
|
219
|
+
const clearSafetyTimer = () => {
|
|
220
|
+
if (safetyTimer) {
|
|
221
|
+
clearTimeout(safetyTimer);
|
|
222
|
+
safetyTimer = null;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
202
225
|
await runAITask({ config, sessionManager }, { userId, chatId, workDir, sessionId, convId, platform: 'wework', taskKey }, prompt, toolAdapter, {
|
|
203
226
|
throttleMs: WEWORK_THROTTLE_MS,
|
|
204
227
|
streamUpdate: async (content, toolNote) => {
|
|
@@ -217,6 +240,7 @@ export function setupWeWorkHandlers(config, sessionManager) {
|
|
|
217
240
|
await updateMessage(chatId, msgId, `Error: ${error}`, 'error', buildErrorNote(), toolId, reqId);
|
|
218
241
|
},
|
|
219
242
|
extraCleanup: () => {
|
|
243
|
+
clearSafetyTimer();
|
|
220
244
|
stopTyping();
|
|
221
245
|
runningTasks.delete(taskKey);
|
|
222
246
|
},
|
|
@@ -101,6 +101,18 @@ function formatWeWorkMessage(title, content, status, note) {
|
|
|
101
101
|
return message;
|
|
102
102
|
}
|
|
103
103
|
const streamStates = new Map();
|
|
104
|
+
// Periodic cleanup of expired/orphaned stream states
|
|
105
|
+
const STREAM_CLEANUP_INTERVAL_MS = 5 * 60 * 1000; // 5 minutes
|
|
106
|
+
setInterval(() => {
|
|
107
|
+
const now = Date.now();
|
|
108
|
+
for (const [id, state] of streamStates) {
|
|
109
|
+
if (now - state.createdAt >= STREAM_SAFE_TTL_MS) {
|
|
110
|
+
state.closed = true;
|
|
111
|
+
streamStates.delete(id);
|
|
112
|
+
log.info(`Cleaned up expired stream state: ${id}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}, STREAM_CLEANUP_INTERVAL_MS);
|
|
104
116
|
function sleep(ms) {
|
|
105
117
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
106
118
|
}
|
|
@@ -187,6 +199,12 @@ export async function updateMessage(chatId, streamId, content, status, note, too
|
|
|
187
199
|
return;
|
|
188
200
|
if (Date.now() - state.createdAt >= STREAM_SAFE_TTL_MS) {
|
|
189
201
|
markExpired(state, streamId);
|
|
202
|
+
// Stream expired - fall back to text delivery for errors and final states
|
|
203
|
+
if (status === 'error' || status === 'done') {
|
|
204
|
+
const reqIdUsed = getReqId(reqId);
|
|
205
|
+
sendText(reqIdUsed, message);
|
|
206
|
+
log.info(`Stream expired, sent ${status} via text fallback: streamId=${streamId}`);
|
|
207
|
+
}
|
|
190
208
|
return;
|
|
191
209
|
}
|
|
192
210
|
state.pendingUpdate = { message, status, reqId };
|
|
@@ -207,17 +225,24 @@ export async function sendFinalMessages(chatId, streamId, fullContent, note, too
|
|
|
207
225
|
const title = getToolTitle(toolId, 'done');
|
|
208
226
|
const parts = splitLongContent(contentToSend, MAX_WEWORK_MESSAGE_LENGTH);
|
|
209
227
|
const finalMessage = formatWeWorkMessage(title, parts[0], 'done', parts.length > 1 ? `内容较长,已分段发送 (1/${parts.length})` : note);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
228
|
+
const state = streamStates.get(streamId);
|
|
229
|
+
const shouldFallbackToText = !!state && (state.expired || Date.now() - state.createdAt >= STREAM_SAFE_TTL_MS);
|
|
230
|
+
// 先发一条「输出中」带正文,再发 finish 的最终条,避免企微端一直停在「思考中」不刷新
|
|
231
|
+
// 独立 try-catch:即使此步失败,仍须发送 finish=true,否则企微永远卡在「正在思考」
|
|
232
|
+
if (!shouldFallbackToText && state && contentToSend.length > 0) {
|
|
233
|
+
try {
|
|
215
234
|
await updateMessage(chatId, streamId, contentToSend, 'streaming', note, toolId, reqId);
|
|
216
235
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
state.pendingUpdate = undefined;
|
|
236
|
+
catch (err) {
|
|
237
|
+
log.warn('Pre-finish streaming update failed, will still send finish=true:', err);
|
|
220
238
|
}
|
|
239
|
+
}
|
|
240
|
+
if (state) {
|
|
241
|
+
state.closed = true;
|
|
242
|
+
state.pendingUpdate = undefined;
|
|
243
|
+
}
|
|
244
|
+
// finish=true 是关键:必须保证发出,否则企微 UI 永远停留在「正在思考」
|
|
245
|
+
try {
|
|
221
246
|
if (!shouldFallbackToText) {
|
|
222
247
|
if (state) {
|
|
223
248
|
const elapsed = Date.now() - state.lastSentAt;
|
|
@@ -232,21 +257,28 @@ export async function sendFinalMessages(chatId, streamId, fullContent, note, too
|
|
|
232
257
|
sendText(getReqId(reqId), finalMessage);
|
|
233
258
|
log.info(`Final stream expired, sent text fallback instead: streamId=${streamId}`);
|
|
234
259
|
}
|
|
235
|
-
streamStates.delete(streamId);
|
|
236
|
-
for (let i = 1; i < parts.length; i++) {
|
|
237
|
-
try {
|
|
238
|
-
const partContent = `${parts[i]}\n\n_*(续 ${i + 1}/${parts.length})*_`;
|
|
239
|
-
const partMessage = formatWeWorkMessage(title, partContent, 'done', i === parts.length - 1 ? note : undefined);
|
|
240
|
-
sendText(getReqId(reqId), partMessage);
|
|
241
|
-
log.info(`Final message part ${i + 1}/${parts.length} sent`);
|
|
242
|
-
}
|
|
243
|
-
catch (err) {
|
|
244
|
-
log.error(`Failed to send part ${i + 1}:`, err);
|
|
245
|
-
}
|
|
246
|
-
}
|
|
247
260
|
}
|
|
248
261
|
catch (err) {
|
|
249
|
-
log.
|
|
262
|
+
log.warn('Primary finish send failed, trying text fallback:', err);
|
|
263
|
+
try {
|
|
264
|
+
sendText(getReqId(reqId), finalMessage);
|
|
265
|
+
log.info(`Fallback text sent after primary finish failure, streamId=${streamId}`);
|
|
266
|
+
}
|
|
267
|
+
catch (fallbackErr) {
|
|
268
|
+
log.error('Both primary and fallback finish sends failed:', fallbackErr);
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
streamStates.delete(streamId);
|
|
272
|
+
for (let i = 1; i < parts.length; i++) {
|
|
273
|
+
try {
|
|
274
|
+
const partContent = `${parts[i]}\n\n_*(续 ${i + 1}/${parts.length})*_`;
|
|
275
|
+
const partMessage = formatWeWorkMessage(title, partContent, 'done', i === parts.length - 1 ? note : undefined);
|
|
276
|
+
sendText(getReqId(reqId), partMessage);
|
|
277
|
+
log.info(`Final message part ${i + 1}/${parts.length} sent`);
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
log.error(`Failed to send part ${i + 1}:`, err);
|
|
281
|
+
}
|
|
250
282
|
}
|
|
251
283
|
}
|
|
252
284
|
/**
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@wu529778790/open-im",
|
|
3
|
-
"version": "1.8.
|
|
3
|
+
"version": "1.8.2",
|
|
4
4
|
"description": "Multi-platform IM bridge for AI CLI tools (Claude, Codex, CodeBuddy)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -46,7 +46,7 @@
|
|
|
46
46
|
"url": "https://github.com/wu529778790/open-im/issues"
|
|
47
47
|
},
|
|
48
48
|
"dependencies": {
|
|
49
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
49
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.86",
|
|
50
50
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
51
51
|
"@wu529778790/open-im": "^1.8.1-beta.8",
|
|
52
52
|
"centrifuge": "^5.3.0",
|