@wu529778790/open-im 1.8.1-beta.16 → 1.8.1-beta.18

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.
@@ -8,7 +8,7 @@ export class AccessControl {
8
8
  }
9
9
  isAllowed(userId) {
10
10
  if (this.allowedUserIds.size === 0) {
11
- log.debug(`Allowing user ${userId} (no whitelist configured)`);
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
  }
@@ -45,43 +60,43 @@ async function getOrCreateSession(sessionId, workDir, model, permissionMode) {
45
60
  };
46
61
  const baseUrl = process.env.ANTHROPIC_BASE_URL ?? '(default)';
47
62
  log.info(`[V2] getOrCreateSession model param=${String(model ?? '')} resolved=${resolvedModel} baseUrl=${baseUrl} workDir=${workDir}`);
48
- let session;
49
- // SDK V2 的 SDKSessionOptions 没有 cwd 字段,
50
- // 需要通过 process.chdir() 临时切换工作目录。
51
- // 因为 createSession/resumeSession 是同步调用,且 JS 单线程,所以是安全的。
52
- const originalCwd = process.cwd();
53
- try {
54
- if (workDir && workDir !== originalCwd) {
55
- process.chdir(workDir);
56
- }
57
- if (sessionId) {
58
- // 尝试恢复已有会话
59
- try {
60
- log.info(`Attempting to resume session: ${sessionId}`);
61
- session = unstable_v2_resumeSession(sessionId, sessionOptions);
62
- activeSessions.set(sessionId, session);
63
- log.info(`Successfully resumed session: ${sessionId}`);
64
- return { session, sessionId };
63
+ // Use mutex to serialize process.chdir() calls across concurrent users
64
+ return withChdirMutex(() => {
65
+ let session;
66
+ const originalCwd = process.cwd();
67
+ try {
68
+ if (workDir && workDir !== originalCwd) {
69
+ process.chdir(workDir);
65
70
  }
66
- catch (err) {
67
- log.warn(`Failed to resume session ${sessionId}, creating new one: ${err}`);
68
- // 恢复失败,创建新会话
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
+ }
69
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 };
70
93
  }
71
- // 创建新会话
72
- session = unstable_v2_createSession(sessionOptions);
73
- // 新会话的 sessionId 需要从第一个消息中获取
74
- // 暂时返回 undefined,稍后在 init 消息中获取
75
- const tempId = `pending-${Date.now()}`;
76
- activeSessions.set(tempId, session);
77
- log.info(`Created new session (tempId: ${tempId})`);
78
- return { session, sessionId: tempId };
79
- }
80
- finally {
81
- if (workDir && workDir !== originalCwd) {
82
- process.chdir(originalCwd);
94
+ finally {
95
+ if (workDir && workDir !== originalCwd) {
96
+ process.chdir(originalCwd);
97
+ }
83
98
  }
84
- }
99
+ });
85
100
  }
86
101
  export class ClaudeSDKAdapter {
87
102
  toolId = 'claude-sdk';
@@ -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
- request.on("data", (chunk) => chunks.push(Buffer.from(chunk)));
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 { /* ignore */ }
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
- try {
150
- cardHandle = await sendThinkingCard(chatId, toolId);
151
- }
152
- catch (err) {
153
- log.error('Failed to send thinking card:', err);
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
- else {
160
- try {
161
- await sendTextReply(chatId, '启动 AI 处理失败,请重试。');
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
- catch { /* ignore */ }
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
- /* ignore */
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");
@@ -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
- ws.send(JSON.stringify({ op: 1, d: seq }));
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) {
@@ -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 { /* ignore */ }
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) };
@@ -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
- return realpath(resolved);
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) {
@@ -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 { /* ignore */ }
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 {
@@ -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: "🔵",
@@ -14,7 +14,7 @@ function nested(obj, ...keys) {
14
14
  export class QClawAPI {
15
15
  env;
16
16
  guid;
17
- loginKey = 'm83qdao0AmE5';
17
+ loginKey = process.env.QCLAW_LOGIN_KEY || 'm83qdao0AmE5';
18
18
  jwtToken = '';
19
19
  userId = '';
20
20
  constructor(env, guid, jwtToken = '') {
@@ -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
- // Fall through to metadata-only prompt.
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 { /* ignore */ }
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);
@@ -121,7 +121,8 @@ export async function buildMediaPrompt(data, kind) {
121
121
  text: contextText,
122
122
  });
123
123
  }
124
- catch {
124
+ catch (err) {
125
+ log.warn('Failed to download WeWork image, falling back to URL reference:', err);
125
126
  imageReference = `Remote image URL: ${imagePayload.url}`;
126
127
  }
127
128
  }
@@ -146,8 +147,8 @@ export async function buildMediaPrompt(data, kind) {
146
147
  text: contextText,
147
148
  });
148
149
  }
149
- catch {
150
- // Fall back to metadata-only prompt.
150
+ catch (err) {
151
+ log.warn('Failed to download WeWork media, falling back to metadata-only prompt:', err);
151
152
  }
152
153
  }
153
154
  return buildMediaMetadataPrompt({
@@ -196,7 +197,9 @@ export function setupWeWorkHandlers(config, sessionManager) {
196
197
  try {
197
198
  await sendTextReply(chatId, '启动 AI 处理失败,请重试。', reqId);
198
199
  }
199
- catch { /* ignore */ }
200
+ catch (err) {
201
+ log.warn('Failed to send startup error reply:', err);
202
+ }
200
203
  return;
201
204
  }
202
205
  const stopTyping = startTypingLoop(chatId);
@@ -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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wu529778790/open-im",
3
- "version": "1.8.1-beta.16",
3
+ "version": "1.8.1-beta.18",
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.76",
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",