@wu529778790/open-im 1.8.1-beta.9 → 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 +85 -66
- 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 +109 -7
- package/dist/dingtalk/client.js +11 -3
- package/dist/dingtalk/event-handler.js +7 -3
- package/dist/dingtalk/message-sender.js +13 -0
- package/dist/feishu/event-handler.js +127 -11
- package/dist/index.js +9 -0
- package/dist/manager-control.js +7 -0
- package/dist/qq/client.js +111 -88
- 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 +81 -4
- package/dist/wechat/event-handler.js +7 -4
- package/dist/wework/client.js +32 -6
- package/dist/wework/event-handler.js +28 -4
- package/dist/wework/message-sender.js +53 -21
- package/package.json +2 -2
|
@@ -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
|
}
|
|
@@ -17,6 +17,99 @@ import { buildSavedMediaPrompt } from '../shared/media-analysis-prompt.js';
|
|
|
17
17
|
import { buildMediaContext } from '../shared/media-context.js';
|
|
18
18
|
import { buildProgressNote } from '../shared/message-note.js';
|
|
19
19
|
const log = createLogger('FeishuHandler');
|
|
20
|
+
/**
|
|
21
|
+
* 从异常中提取飞书 API 错误码
|
|
22
|
+
*/
|
|
23
|
+
function extractFeishuErrorCode(err) {
|
|
24
|
+
const e = err;
|
|
25
|
+
if (e?.response?.data?.code)
|
|
26
|
+
return e.response.data.code;
|
|
27
|
+
if (e?.code)
|
|
28
|
+
return e.code;
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* 根据错误码判断是否为权限不足
|
|
33
|
+
*/
|
|
34
|
+
function isPermissionError(err) {
|
|
35
|
+
const code = extractFeishuErrorCode(err);
|
|
36
|
+
if (!code) {
|
|
37
|
+
// 非标准错误:检查 message 中是否包含权限关键词
|
|
38
|
+
const msg = err?.message ?? String(err);
|
|
39
|
+
return /permission|权限|scope|not authorized|no access|forbidden/i.test(msg);
|
|
40
|
+
}
|
|
41
|
+
// 飞书常见权限错误码
|
|
42
|
+
return [
|
|
43
|
+
99991400, // 权限不足
|
|
44
|
+
99991401, // 没有API权限
|
|
45
|
+
99991663, // 应用未获取 scope
|
|
46
|
+
99991672, // 应用未开通相关能力
|
|
47
|
+
99991670, // 应用未上架/未授权
|
|
48
|
+
99991668, // 应用可见性限制
|
|
49
|
+
].includes(code);
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* 构建飞书权限配置指引消息
|
|
53
|
+
*/
|
|
54
|
+
function buildPermissionGuideMessage(err) {
|
|
55
|
+
const code = extractFeishuErrorCode(err);
|
|
56
|
+
const codeHint = code ? ` (错误码: ${code})` : '';
|
|
57
|
+
return [
|
|
58
|
+
'⚠️ **飞书应用权限不足,无法发送消息**' + codeHint,
|
|
59
|
+
'',
|
|
60
|
+
'请按以下步骤开通所需权限:',
|
|
61
|
+
'',
|
|
62
|
+
'**1. 进入飞书开放平台**',
|
|
63
|
+
'👉 https://open.feishu.cn/app',
|
|
64
|
+
'',
|
|
65
|
+
'**2. 找到你的应用,进入「权限管理」**',
|
|
66
|
+
'',
|
|
67
|
+
'**3. 开通以下权限(搜索权限名称添加):**',
|
|
68
|
+
'• `im:message` — 获取与发送单聊、群组消息',
|
|
69
|
+
'• `im:message:send_as_bot` — 以应用身份发消息',
|
|
70
|
+
'• `im:resource` — 获取与上传图片或文件资源',
|
|
71
|
+
'• `im:chat` — 获取群组信息',
|
|
72
|
+
'',
|
|
73
|
+
'**4. 如需使用卡片打字机效果,还需开通:**',
|
|
74
|
+
'• `cardkit:card` — CardKit 卡片管理',
|
|
75
|
+
'',
|
|
76
|
+
'**5. 发布版本**',
|
|
77
|
+
'权限修改后需点击「创建版本」→「发布」,管理员审批后生效。',
|
|
78
|
+
'',
|
|
79
|
+
'📖 详细文档:https://open.feishu.cn/document/ukTMukTMukTM/ucTM5YjL3ETO04yNxkDN',
|
|
80
|
+
].join('\n');
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* 发送权限错误提示,依次尝试:卡片 → 纯文本 → open_id
|
|
84
|
+
*/
|
|
85
|
+
async function sendPermissionFallback(chatId, guide) {
|
|
86
|
+
// 1. 先尝试 sendTextReply(发卡片消息)
|
|
87
|
+
try {
|
|
88
|
+
await sendTextReply(chatId, guide);
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
catch (err) {
|
|
92
|
+
log.warn('Card-based reply failed, falling back to plain text:', err);
|
|
93
|
+
}
|
|
94
|
+
// 2. 降级为纯文本消息
|
|
95
|
+
try {
|
|
96
|
+
const client = (await import('./client.js')).getClient();
|
|
97
|
+
const plainGuide = guide.replace(/\*\*/g, '').replace(/`/g, '');
|
|
98
|
+
await client.im.message.create({
|
|
99
|
+
data: {
|
|
100
|
+
receive_id: chatId,
|
|
101
|
+
msg_type: 'text',
|
|
102
|
+
content: JSON.stringify({ text: plainGuide }),
|
|
103
|
+
},
|
|
104
|
+
params: { receive_id_type: 'chat_id' },
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
catch (err) {
|
|
109
|
+
log.warn('Plain text reply also failed:', err);
|
|
110
|
+
}
|
|
111
|
+
log.error('All fallback methods failed to send permission guide');
|
|
112
|
+
}
|
|
20
113
|
async function downloadFeishuMessageResource(client, messageId, fileKey, type, options) {
|
|
21
114
|
const targetPath = createMediaTargetPath(options?.fallbackExtension ?? 'bin', options?.basenameHint ?? fileKey);
|
|
22
115
|
const response = await client.im.messageResource.get({
|
|
@@ -57,16 +150,38 @@ export function setupFeishuHandlers(config, sessionManager) {
|
|
|
57
150
|
const toolId = aiCommand;
|
|
58
151
|
// 使用 CardKit 打字机效果(80ms 节流,约 12 次/秒,比 patch 5 QPS 更流畅)
|
|
59
152
|
let cardHandle;
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
}
|
|
63
|
-
catch (err) {
|
|
64
|
-
log.error('Failed to send thinking card:', err);
|
|
153
|
+
const MAX_SEND_RETRIES = 3;
|
|
154
|
+
for (let attempt = 1; attempt <= MAX_SEND_RETRIES; attempt++) {
|
|
65
155
|
try {
|
|
66
|
-
await
|
|
156
|
+
cardHandle = await sendThinkingCard(chatId, toolId);
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
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
|
+
});
|
|
174
|
+
}
|
|
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;
|
|
67
184
|
}
|
|
68
|
-
catch { /* ignore */ }
|
|
69
|
-
return;
|
|
70
185
|
}
|
|
71
186
|
const { messageId: msgId, cardId } = cardHandle;
|
|
72
187
|
const stopTyping = startTypingLoop(chatId);
|
|
@@ -123,7 +238,8 @@ export function setupFeishuHandlers(config, sessionManager) {
|
|
|
123
238
|
try {
|
|
124
239
|
obj = JSON.parse(raw);
|
|
125
240
|
}
|
|
126
|
-
catch {
|
|
241
|
+
catch (err) {
|
|
242
|
+
log.debug('Failed to parse action value as JSON:', err);
|
|
127
243
|
return null;
|
|
128
244
|
}
|
|
129
245
|
}
|
|
@@ -176,8 +292,8 @@ export function setupFeishuHandlers(config, sessionManager) {
|
|
|
176
292
|
}
|
|
177
293
|
actionData = parsed;
|
|
178
294
|
}
|
|
179
|
-
catch {
|
|
180
|
-
|
|
295
|
+
catch (err) {
|
|
296
|
+
log.debug('Failed to parse card action data:', err);
|
|
181
297
|
}
|
|
182
298
|
if (actionData?.action === 'stop' && actionData.card_id) {
|
|
183
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
|
@@ -17,6 +17,7 @@ let stopped = false;
|
|
|
17
17
|
let seq = null;
|
|
18
18
|
let sessionId = null;
|
|
19
19
|
let reconnectAttempt = 0;
|
|
20
|
+
let connecting = false; // 防止并发 connectWebSocket
|
|
20
21
|
let currentConfig = null;
|
|
21
22
|
let currentHandler = null;
|
|
22
23
|
let tokenState = null;
|
|
@@ -143,103 +144,125 @@ function startHeartbeat(intervalMs) {
|
|
|
143
144
|
heartbeatTimer = setInterval(() => {
|
|
144
145
|
if (!ws || ws.readyState !== WebSocket.OPEN)
|
|
145
146
|
return;
|
|
146
|
-
|
|
147
|
+
try {
|
|
148
|
+
ws.send(JSON.stringify({ op: 1, d: seq }));
|
|
149
|
+
}
|
|
150
|
+
catch (err) {
|
|
151
|
+
log.warn('QQ heartbeat send failed:', err);
|
|
152
|
+
}
|
|
147
153
|
}, intervalMs);
|
|
148
154
|
}
|
|
149
155
|
async function connectWebSocket(config, handler) {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
if (typeof payload.s === "number")
|
|
170
|
-
seq = payload.s;
|
|
171
|
-
if (payload.op === 10) {
|
|
172
|
-
const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
|
|
173
|
-
startHeartbeat(heartbeatInterval);
|
|
174
|
-
socket.send(JSON.stringify({
|
|
175
|
-
op: sessionId ? 6 : 2,
|
|
176
|
-
d: sessionId
|
|
177
|
-
? {
|
|
178
|
-
token: `QQBot ${token}`,
|
|
179
|
-
session_id: sessionId,
|
|
180
|
-
seq,
|
|
181
|
-
}
|
|
182
|
-
: {
|
|
183
|
-
token: `QQBot ${token}`,
|
|
184
|
-
intents: INTENTS.GROUP_AND_C2C |
|
|
185
|
-
INTENTS.DIRECT_MESSAGE |
|
|
186
|
-
INTENTS.PUBLIC_GUILD_MESSAGES,
|
|
187
|
-
properties: {
|
|
188
|
-
os: process.platform,
|
|
189
|
-
browser: "open-im",
|
|
190
|
-
device: "open-im",
|
|
191
|
-
},
|
|
192
|
-
},
|
|
193
|
-
}));
|
|
194
|
-
return;
|
|
195
|
-
}
|
|
196
|
-
if (payload.op === 0 && payload.t === "READY") {
|
|
197
|
-
sessionId = String(payload.d?.session_id ?? "");
|
|
198
|
-
settle(resolve);
|
|
156
|
+
// 防止并发连接
|
|
157
|
+
if (connecting) {
|
|
158
|
+
log.warn("QQ gateway connection already in progress");
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
connecting = true;
|
|
162
|
+
try {
|
|
163
|
+
const gatewayUrl = await getGatewayUrl(config);
|
|
164
|
+
const token = await fetchAccessToken(config);
|
|
165
|
+
await new Promise((resolve, reject) => {
|
|
166
|
+
const socket = new WebSocket(gatewayUrl);
|
|
167
|
+
ws = socket;
|
|
168
|
+
let settled = false;
|
|
169
|
+
let readyTimeoutId = setTimeout(() => {
|
|
170
|
+
readyTimeoutId = null;
|
|
171
|
+
settle(() => reject(new Error("QQ gateway ready timeout")));
|
|
172
|
+
}, 15000);
|
|
173
|
+
const settle = (fn) => {
|
|
174
|
+
if (settled)
|
|
199
175
|
return;
|
|
176
|
+
settled = true;
|
|
177
|
+
if (readyTimeoutId) {
|
|
178
|
+
clearTimeout(readyTimeoutId);
|
|
179
|
+
readyTimeoutId = null;
|
|
200
180
|
}
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
181
|
+
fn();
|
|
182
|
+
};
|
|
183
|
+
socket.on("open", () => {
|
|
184
|
+
log.info("QQ gateway connected");
|
|
185
|
+
reconnectAttempt = 0;
|
|
186
|
+
});
|
|
187
|
+
socket.on("message", async (raw) => {
|
|
188
|
+
try {
|
|
189
|
+
const payload = JSON.parse(raw.toString());
|
|
190
|
+
if (typeof payload.s === "number")
|
|
191
|
+
seq = payload.s;
|
|
192
|
+
if (payload.op === 10) {
|
|
193
|
+
const heartbeatInterval = Number(payload.d?.heartbeat_interval ?? 30000);
|
|
194
|
+
startHeartbeat(heartbeatInterval);
|
|
195
|
+
socket.send(JSON.stringify({
|
|
196
|
+
op: sessionId ? 6 : 2,
|
|
197
|
+
d: sessionId
|
|
198
|
+
? {
|
|
199
|
+
token: `QQBot ${token}`,
|
|
200
|
+
session_id: sessionId,
|
|
201
|
+
seq,
|
|
202
|
+
}
|
|
203
|
+
: {
|
|
204
|
+
token: `QQBot ${token}`,
|
|
205
|
+
intents: INTENTS.GROUP_AND_C2C |
|
|
206
|
+
INTENTS.DIRECT_MESSAGE |
|
|
207
|
+
INTENTS.PUBLIC_GUILD_MESSAGES,
|
|
208
|
+
properties: {
|
|
209
|
+
os: process.platform,
|
|
210
|
+
browser: "open-im",
|
|
211
|
+
device: "open-im",
|
|
212
|
+
},
|
|
213
|
+
},
|
|
214
|
+
}));
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (payload.op === 0 && payload.t === "READY") {
|
|
218
|
+
sessionId = String(payload.d?.session_id ?? "");
|
|
219
|
+
settle(resolve);
|
|
220
|
+
return;
|
|
221
|
+
}
|
|
222
|
+
if (payload.op === 0 && payload.t === "RESUMED") {
|
|
223
|
+
settle(resolve);
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
const event = normalizeInboundEvent(payload);
|
|
227
|
+
if (event && (event.content || (event.attachments?.length ?? 0) > 0)) {
|
|
228
|
+
await handler(event);
|
|
229
|
+
}
|
|
204
230
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
await handler(event);
|
|
231
|
+
catch (error) {
|
|
232
|
+
log.error("Failed to handle QQ gateway payload:", error);
|
|
208
233
|
}
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
log.error("
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
tokenState = null;
|
|
226
|
-
sessionId = null;
|
|
227
|
-
seq = null;
|
|
228
|
-
}
|
|
229
|
-
const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
230
|
-
reconnectAttempt += 1;
|
|
231
|
-
reconnectTimer = setTimeout(() => {
|
|
232
|
-
if (currentConfig && currentHandler) {
|
|
233
|
-
connectWebSocket(currentConfig, currentHandler).catch((err) => {
|
|
234
|
-
log.error("QQ reconnect failed:", err);
|
|
235
|
-
});
|
|
234
|
+
});
|
|
235
|
+
socket.on("error", (error) => {
|
|
236
|
+
log.error("QQ gateway error:", error);
|
|
237
|
+
settle(() => reject(error));
|
|
238
|
+
});
|
|
239
|
+
socket.on("close", (code, reason) => {
|
|
240
|
+
settle(() => { }); // 清理 ready timeout
|
|
241
|
+
clearTimers();
|
|
242
|
+
ws = null;
|
|
243
|
+
log.info(`QQ gateway closed: ${code} ${reason.toString()}`);
|
|
244
|
+
if (stopped)
|
|
245
|
+
return;
|
|
246
|
+
if (code === 4004 || code === 4006 || code === 4007 || code === 4009) {
|
|
247
|
+
tokenState = null;
|
|
248
|
+
sessionId = null;
|
|
249
|
+
seq = null;
|
|
236
250
|
}
|
|
237
|
-
|
|
251
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(reconnectAttempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
252
|
+
reconnectAttempt += 1;
|
|
253
|
+
reconnectTimer = setTimeout(() => {
|
|
254
|
+
if (currentConfig && currentHandler) {
|
|
255
|
+
connectWebSocket(currentConfig, currentHandler).catch((err) => {
|
|
256
|
+
log.error("QQ reconnect failed:", err);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}, delay);
|
|
260
|
+
});
|
|
238
261
|
});
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
262
|
+
}
|
|
263
|
+
finally {
|
|
264
|
+
connecting = false;
|
|
265
|
+
}
|
|
243
266
|
}
|
|
244
267
|
export function getQQBot() {
|
|
245
268
|
if (!client || !currentConfig) {
|
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() {
|