evolclaw 2.2.0 → 2.4.0
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/README.md +49 -27
- package/data/evolclaw.sample.json +6 -3
- package/dist/agents/claude-runner.js +125 -52
- package/dist/agents/codex-runner.js +10 -5
- package/dist/agents/gemini-runner.js +425 -0
- package/dist/channels/aun.js +283 -95
- package/dist/channels/feishu.js +556 -96
- package/dist/channels/wechat.js +98 -74
- package/dist/cli.js +232 -57
- package/dist/config.js +185 -31
- package/dist/core/channel-loader.js +11 -4
- package/dist/core/command-handler.js +803 -247
- package/dist/core/interaction-router.js +68 -0
- package/dist/core/message/message-bridge.js +217 -0
- package/dist/core/{message-processor.js → message/message-processor.js} +411 -124
- package/dist/core/{message-queue.js → message/message-queue.js} +1 -1
- package/dist/{utils → core/message}/stream-debouncer.js +1 -1
- package/dist/{utils → core/message}/stream-flusher.js +73 -13
- package/dist/core/permission.js +212 -11
- package/dist/core/{adapters → session/adapters}/claude-session-file-adapter.js +2 -2
- package/dist/core/{adapters → session/adapters}/codex-session-file-adapter.js +117 -52
- package/dist/core/session/adapters/gemini-session-file-adapter.js +177 -0
- package/dist/{utils → core/session}/session-file-health.js +1 -1
- package/dist/core/{session-manager.js → session/session-manager.js} +61 -11
- package/dist/index.js +140 -57
- package/dist/{core/ipc-server.js → ipc.js} +36 -1
- package/dist/types.js +3 -0
- package/dist/utils/cross-platform.js +38 -1
- package/dist/utils/error-utils.js +130 -5
- package/dist/utils/init-channel.js +649 -0
- package/dist/utils/init.js +55 -150
- package/dist/utils/logger.js +8 -3
- package/dist/utils/media-cache.js +207 -0
- package/dist/{core → utils}/stats-collector.js +16 -0
- package/package.json +3 -3
- package/dist/core/message-bridge.js +0 -187
- package/dist/utils/init-feishu.js +0 -263
- package/dist/utils/init-wechat.js +0 -172
- package/dist/utils/ipc-client.js +0 -36
- package/dist/utils/permission-utils.js +0 -71
- /package/dist/{utils → core/message}/message-cache.js +0 -0
- /package/dist/{utils → core/message}/stream-idle-monitor.js +0 -0
- /package/dist/core/{session-file-adapter.js → session/session-file-adapter.js} +0 -0
package/dist/utils/init.js
CHANGED
|
@@ -343,42 +343,13 @@ async function initFeishuManual(rl, config) {
|
|
|
343
343
|
return true;
|
|
344
344
|
}
|
|
345
345
|
// ==================== AUN Environment Check ====================
|
|
346
|
-
|
|
347
|
-
console.log('\n🔍 AUN 环境检查...\n');
|
|
348
|
-
// Check python3 (needed for aun_cli.py AID management)
|
|
349
|
-
if (!commandExists('python3')) {
|
|
350
|
-
console.log(' ✗ python3 未找到');
|
|
351
|
-
console.log(' → 请先安装 Python 3: https://www.python.org/downloads/');
|
|
352
|
-
const answer = (await ask(rl, ' → 返回重新选择渠道?[Y/n] ')).trim().toLowerCase();
|
|
353
|
-
if (answer === 'n' || answer === 'no')
|
|
354
|
-
process.exit(0);
|
|
355
|
-
return false;
|
|
356
|
-
}
|
|
357
|
-
console.log(' ✓ python3');
|
|
358
|
-
// Check aun_core
|
|
359
|
-
try {
|
|
360
|
-
execFileSync('python3', ['-c', 'import aun_core'], { encoding: 'utf-8', stdio: 'pipe' });
|
|
361
|
-
console.log(' ✓ aun_core');
|
|
362
|
-
}
|
|
363
|
-
catch {
|
|
364
|
-
console.log(' ✗ aun_core 未安装');
|
|
365
|
-
console.log(' → 请安装: pip3 install aun-core');
|
|
366
|
-
const answer = (await ask(rl, ' → 返回重新选择渠道?[Y/n] ')).trim().toLowerCase();
|
|
367
|
-
if (answer === 'n' || answer === 'no')
|
|
368
|
-
process.exit(0);
|
|
369
|
-
return false;
|
|
370
|
-
}
|
|
371
|
-
console.log('');
|
|
372
|
-
return true;
|
|
373
|
-
}
|
|
346
|
+
// Moved to init-channel.ts
|
|
374
347
|
// ==================== Rich Content Renderer ====================
|
|
375
348
|
async function offerRichContentRenderer(rl, config) {
|
|
376
349
|
const answer = (await ask(rl, '\n是否启用 LaTeX + Mermaid 渲染模块(约 35MB)?[y/N] ')).trim().toLowerCase();
|
|
377
350
|
const enableRich = answer === 'y' || answer === 'yes';
|
|
378
|
-
//
|
|
379
|
-
|
|
380
|
-
config.channels.feishu.enableRichContent = enableRich;
|
|
381
|
-
}
|
|
351
|
+
// 记录用户选择到全局配置
|
|
352
|
+
config.enableRichContent = enableRich;
|
|
382
353
|
if (!enableRich) {
|
|
383
354
|
console.log(' ✓ 已跳过富内容渲染模块安装');
|
|
384
355
|
return;
|
|
@@ -399,125 +370,11 @@ async function offerRichContentRenderer(rl, config) {
|
|
|
399
370
|
}
|
|
400
371
|
console.log(' → 可稍后手动安装: npm install -g katex mermaid');
|
|
401
372
|
// 安装失败时,将配置设为 false
|
|
402
|
-
|
|
403
|
-
config.channels.feishu.enableRichContent = false;
|
|
404
|
-
}
|
|
373
|
+
config.enableRichContent = false;
|
|
405
374
|
}
|
|
406
375
|
}
|
|
407
376
|
// ==================== AUN AID Helpers ====================
|
|
408
|
-
|
|
409
|
-
const labels = name.split('.');
|
|
410
|
-
return labels.length >= 3 && labels.every(l => /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?$/.test(l));
|
|
411
|
-
}
|
|
412
|
-
async function setupAunAid(rl, config) {
|
|
413
|
-
const pythonBin = config.channels?.aun?.pythonBin || process.env.AUN_PYTHON || 'python3';
|
|
414
|
-
const cliScript = path.join(getPackageRoot(), 'aun', 'aun_cli.py');
|
|
415
|
-
let aid = '';
|
|
416
|
-
let gatewayPort;
|
|
417
|
-
// Outer loop: allows retrying with a different AID
|
|
418
|
-
while (true) {
|
|
419
|
-
// Ask AID with format validation
|
|
420
|
-
aid = '';
|
|
421
|
-
while (!aid) {
|
|
422
|
-
aid = (await ask(rl, ' AUN Agent ID (例: mybot.agentid.pub): ')).trim();
|
|
423
|
-
if (!aid) {
|
|
424
|
-
console.log(' ⚠ 不能为空');
|
|
425
|
-
continue;
|
|
426
|
-
}
|
|
427
|
-
if (!isValidAid(aid)) {
|
|
428
|
-
console.log(' ⚠ 无效 AID 格式(需要合法域名,至少三级,如 alice.agentid.pub)');
|
|
429
|
-
aid = '';
|
|
430
|
-
}
|
|
431
|
-
}
|
|
432
|
-
const portStr = (await ask(rl, ' Gateway 端口 [留空使用默认 443]: ')).trim();
|
|
433
|
-
gatewayPort = portStr ? parseInt(portStr, 10) : undefined;
|
|
434
|
-
if (gatewayPort !== undefined && (isNaN(gatewayPort) || gatewayPort < 1 || gatewayPort > 65535)) {
|
|
435
|
-
console.log(' ⚠ 端口号无效,使用默认 443');
|
|
436
|
-
gatewayPort = undefined;
|
|
437
|
-
}
|
|
438
|
-
// Check if AID exists locally
|
|
439
|
-
const aidDir = path.join(os.homedir(), '.aun', 'AIDs', aid);
|
|
440
|
-
if (fs.existsSync(aidDir)) {
|
|
441
|
-
console.log(` ✓ AID ${aid} 已存在`);
|
|
442
|
-
break;
|
|
443
|
-
}
|
|
444
|
-
const answer = (await ask(rl, ` ⚠ AID ${aid} 本地不存在,是否创建?[Y/n] `)).trim().toLowerCase();
|
|
445
|
-
if (answer === 'n' || answer === 'no') {
|
|
446
|
-
console.log(' 已跳过 AID 创建(启动时可能连接失败)');
|
|
447
|
-
break;
|
|
448
|
-
}
|
|
449
|
-
// Try creating
|
|
450
|
-
console.log(' 正在创建 AID...');
|
|
451
|
-
const args = [cliScript, 'aid', 'new', aid];
|
|
452
|
-
if (gatewayPort)
|
|
453
|
-
args.push('-p', String(gatewayPort));
|
|
454
|
-
let failed = false;
|
|
455
|
-
try {
|
|
456
|
-
const { stdout, stderr } = await execFileAsync(pythonBin, args, { timeout: 30000, encoding: 'utf-8' });
|
|
457
|
-
const output = (stdout + stderr).trim();
|
|
458
|
-
if (output)
|
|
459
|
-
console.log(` ${output}`);
|
|
460
|
-
// aun_cli.py 可能 exit 0 但输出包含错误
|
|
461
|
-
failed = output.includes('✗') || output.includes('失败');
|
|
462
|
-
}
|
|
463
|
-
catch (e) {
|
|
464
|
-
const msg = e.stderr || e.stdout || e.message || '';
|
|
465
|
-
console.log(` ✗ AID 创建失败: ${msg.trim().slice(0, 200)}`);
|
|
466
|
-
failed = true;
|
|
467
|
-
}
|
|
468
|
-
if (!failed) {
|
|
469
|
-
console.log(` ✓ AID ${aid} 创建成功`);
|
|
470
|
-
break;
|
|
471
|
-
}
|
|
472
|
-
// Creation failed — retry or give up
|
|
473
|
-
const retry = (await ask(rl, ' → 重新输入 (r) / 跳过 (s) / 取消 (c)?[r/s/c] ')).trim().toLowerCase();
|
|
474
|
-
if (retry === 'c')
|
|
475
|
-
return null;
|
|
476
|
-
if (retry === 's')
|
|
477
|
-
break;
|
|
478
|
-
// default: retry with new AID
|
|
479
|
-
}
|
|
480
|
-
return { aid, gatewayPort };
|
|
481
|
-
}
|
|
482
|
-
// ==================== Init AUN (standalone) ====================
|
|
483
|
-
export async function cmdInitAun() {
|
|
484
|
-
const p = resolvePaths();
|
|
485
|
-
if (!fs.existsSync(p.config)) {
|
|
486
|
-
console.log('❌ 配置文件不存在,请先运行 evolclaw init');
|
|
487
|
-
return;
|
|
488
|
-
}
|
|
489
|
-
const config = JSON.parse(fs.readFileSync(p.config, 'utf-8'));
|
|
490
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
491
|
-
try {
|
|
492
|
-
if (config.channels?.aun?.aid) {
|
|
493
|
-
const answer = (await ask(rl, '已有 AUN 配置,是否重新配置?[y/N] ')).trim().toLowerCase();
|
|
494
|
-
if (answer !== 'y' && answer !== 'yes') {
|
|
495
|
-
console.log('已取消');
|
|
496
|
-
return;
|
|
497
|
-
}
|
|
498
|
-
}
|
|
499
|
-
if (!await checkAunEnvironment(rl)) {
|
|
500
|
-
return;
|
|
501
|
-
}
|
|
502
|
-
const result = await setupAunAid(rl, config);
|
|
503
|
-
if (!result)
|
|
504
|
-
return;
|
|
505
|
-
if (!config.channels)
|
|
506
|
-
config.channels = {};
|
|
507
|
-
config.channels.aun = {
|
|
508
|
-
enabled: true,
|
|
509
|
-
aid: result.aid,
|
|
510
|
-
...(result.gatewayPort && { gatewayPort: result.gatewayPort }),
|
|
511
|
-
};
|
|
512
|
-
if (!config.channels.defaultChannel)
|
|
513
|
-
config.channels.defaultChannel = 'aun';
|
|
514
|
-
fs.writeFileSync(p.config, JSON.stringify(config, null, 2) + '\n');
|
|
515
|
-
console.log('\n✓ AUN 配置已写入');
|
|
516
|
-
}
|
|
517
|
-
finally {
|
|
518
|
-
rl.close();
|
|
519
|
-
}
|
|
520
|
-
}
|
|
377
|
+
// Moved to init-channel.ts
|
|
521
378
|
// ==================== Main ====================
|
|
522
379
|
export async function cmdInit() {
|
|
523
380
|
const p = resolvePaths();
|
|
@@ -584,7 +441,7 @@ export async function cmdInit() {
|
|
|
584
441
|
console.log(' 2. 手动输入 App ID/Secret');
|
|
585
442
|
const feishuMethod = (await ask(rl, '请选择 [1]: ')).trim() || '1';
|
|
586
443
|
if (feishuMethod === '1') {
|
|
587
|
-
const { runFeishuQrFlow } = await import('./init-
|
|
444
|
+
const { runFeishuQrFlow } = await import('./init-channel.js');
|
|
588
445
|
const result = await runFeishuQrFlow();
|
|
589
446
|
if (!result) {
|
|
590
447
|
console.log('已取消');
|
|
@@ -606,7 +463,7 @@ export async function cmdInit() {
|
|
|
606
463
|
config.channels.defaultChannel = 'feishu';
|
|
607
464
|
}
|
|
608
465
|
else if (channelChoice === '2') {
|
|
609
|
-
const { runWechatQrFlow } = await import('./init-
|
|
466
|
+
const { runWechatQrFlow } = await import('./init-channel.js');
|
|
610
467
|
const result = await runWechatQrFlow();
|
|
611
468
|
if (!result) {
|
|
612
469
|
console.log('已取消');
|
|
@@ -621,6 +478,7 @@ export async function cmdInit() {
|
|
|
621
478
|
config.channels.defaultChannel = 'wechat';
|
|
622
479
|
}
|
|
623
480
|
else if (channelChoice === '3') {
|
|
481
|
+
const { checkAunEnvironment, setupAunAid } = await import('./init-channel.js');
|
|
624
482
|
const aunReady = await checkAunEnvironment(rl);
|
|
625
483
|
if (!aunReady)
|
|
626
484
|
continue; // 退回重选渠道
|
|
@@ -651,3 +509,50 @@ export async function cmdInit() {
|
|
|
651
509
|
rl.close();
|
|
652
510
|
}
|
|
653
511
|
}
|
|
512
|
+
/**
|
|
513
|
+
* Present instance selection menu when existing instances are found.
|
|
514
|
+
* Returns the user's choice, or null if cancelled.
|
|
515
|
+
*/
|
|
516
|
+
export async function selectInstance(rl, channelType, instances) {
|
|
517
|
+
const typeLabel = channelType === 'feishu' ? '飞书' : channelType === 'wechat' ? '微信' : 'AUN';
|
|
518
|
+
console.log(`\n发现已有 ${typeLabel} 机器人:`);
|
|
519
|
+
const letters = 'abcdefghijklmnopqrstuvwxyz';
|
|
520
|
+
for (let i = 0; i < instances.length; i++) {
|
|
521
|
+
console.log(` ${letters[i]}. ${instances[i].name}`);
|
|
522
|
+
}
|
|
523
|
+
const addLetter = letters[instances.length];
|
|
524
|
+
console.log(` ${addLetter}. 添加新机器人`);
|
|
525
|
+
console.log('');
|
|
526
|
+
const validOptions = letters.slice(0, instances.length + 1).split('');
|
|
527
|
+
let choice = '';
|
|
528
|
+
while (!validOptions.includes(choice)) {
|
|
529
|
+
choice = (await ask(rl, '请选择: ')).trim().toLowerCase();
|
|
530
|
+
if (!validOptions.includes(choice)) {
|
|
531
|
+
console.log(`无效选择,请输入 ${validOptions.join('/')}`);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
const choiceIndex = letters.indexOf(choice);
|
|
535
|
+
if (choiceIndex === instances.length) {
|
|
536
|
+
// Add new — ask for name
|
|
537
|
+
let name = '';
|
|
538
|
+
while (!name) {
|
|
539
|
+
name = (await ask(rl, '请输入新机器人名称: ')).trim();
|
|
540
|
+
if (!name)
|
|
541
|
+
console.log(' 名称不能为空');
|
|
542
|
+
if (instances.some(i => i.name === name)) {
|
|
543
|
+
console.log(` 名称 "${name}" 已存在,请换一个`);
|
|
544
|
+
name = '';
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
return { action: 'add', name };
|
|
548
|
+
}
|
|
549
|
+
// Overwrite — requires confirmation
|
|
550
|
+
const target = instances[choiceIndex];
|
|
551
|
+
console.log(`\n已选择:${target.name}`);
|
|
552
|
+
const confirm = (await ask(rl, `⚠️ 即将覆盖该机器人配置,确认?(y/N) `)).trim().toLowerCase();
|
|
553
|
+
if (confirm !== 'y' && confirm !== 'yes') {
|
|
554
|
+
console.log('已取消');
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
return { action: 'overwrite', index: choiceIndex, name: target.name };
|
|
558
|
+
}
|
package/dist/utils/logger.js
CHANGED
|
@@ -25,10 +25,15 @@ function write(stream, data) {
|
|
|
25
25
|
const line = typeof data === 'string' ? data : JSON.stringify(data);
|
|
26
26
|
stream.write(`${line}\n`);
|
|
27
27
|
}
|
|
28
|
+
export function localTimestamp() {
|
|
29
|
+
const d = new Date();
|
|
30
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
31
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${String(d.getMilliseconds()).padStart(3, '0')}`;
|
|
32
|
+
}
|
|
28
33
|
function log(level, ...args) {
|
|
29
34
|
if (!shouldLog(level))
|
|
30
35
|
return;
|
|
31
|
-
const timestamp =
|
|
36
|
+
const timestamp = localTimestamp();
|
|
32
37
|
const msg = `[${timestamp}] [${level}] ${args.join(' ')}`;
|
|
33
38
|
// 只写文件,不输出到 console(避免重定向时重复)
|
|
34
39
|
write(streams.main, msg);
|
|
@@ -39,9 +44,9 @@ export const logger = {
|
|
|
39
44
|
warn: (...args) => log('WARN', ...args),
|
|
40
45
|
error: (...args) => log('ERROR', ...args),
|
|
41
46
|
message: (data) => {
|
|
42
|
-
write(streams.message, { ts:
|
|
47
|
+
write(streams.message, { ts: localTimestamp(), ...data });
|
|
43
48
|
},
|
|
44
49
|
event: (data) => {
|
|
45
|
-
write(streams.event, { ts:
|
|
50
|
+
write(streams.event, { ts: localTimestamp(), ...data });
|
|
46
51
|
}
|
|
47
52
|
};
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 统一媒体下载/缓存/SSRF防护框架
|
|
3
|
+
*
|
|
4
|
+
* 提供跨通道复用的文件处理能力:
|
|
5
|
+
* - 文件保存到 uploads 目录(自动去重、文件名清洗)
|
|
6
|
+
* - 图片类型白名单 + 大小限制
|
|
7
|
+
* - URL SSRF 防护(域名白名单 + 私有 IP 拦截)
|
|
8
|
+
* - 下载缓存(相同 key 不重复下载)
|
|
9
|
+
*/
|
|
10
|
+
import path from 'path';
|
|
11
|
+
import fs from 'fs';
|
|
12
|
+
import crypto from 'crypto';
|
|
13
|
+
import { logger } from './logger.js';
|
|
14
|
+
// ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
15
|
+
const DEFAULT_MAX_IMAGE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
16
|
+
const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024; // 100 MB
|
|
17
|
+
const ALLOWED_IMAGE_MIMES = new Set([
|
|
18
|
+
'image/png', 'image/jpeg', 'image/gif', 'image/webp',
|
|
19
|
+
]);
|
|
20
|
+
const UPLOADS_SUBDIR = '.evolclaw/uploads';
|
|
21
|
+
// ── SSRF 防护 ─────────────────────────────────────────────────────────────────
|
|
22
|
+
/** 已知安全的 CDN 域名白名单 */
|
|
23
|
+
const ALLOWED_CDN_HOSTS = new Set([
|
|
24
|
+
'novac2c.cdn.weixin.qq.com',
|
|
25
|
+
'open.feishu.cn',
|
|
26
|
+
'internal-api-lark-file.feishu.cn',
|
|
27
|
+
]);
|
|
28
|
+
/** 私有 IP 段正则(IPv4) */
|
|
29
|
+
const PRIVATE_IP_RE = /^(10\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|127\.|0\.|169\.254\.|::1|fc|fd|fe80)/;
|
|
30
|
+
/**
|
|
31
|
+
* 检查 URL 是否安全(非 SSRF)
|
|
32
|
+
* - 拒绝私有 IP
|
|
33
|
+
* - 仅允许 https(或白名单域名的 http)
|
|
34
|
+
* - 可选:域名白名单模式
|
|
35
|
+
*/
|
|
36
|
+
export function validateUrl(url, opts) {
|
|
37
|
+
let parsed;
|
|
38
|
+
try {
|
|
39
|
+
parsed = new URL(url);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { ok: false, reason: `Invalid URL: ${url}` };
|
|
43
|
+
}
|
|
44
|
+
// 协议检查
|
|
45
|
+
if (parsed.protocol !== 'https:' && parsed.protocol !== 'http:') {
|
|
46
|
+
return { ok: false, reason: `Blocked protocol: ${parsed.protocol}` };
|
|
47
|
+
}
|
|
48
|
+
// 私有 IP 检查
|
|
49
|
+
const host = parsed.hostname;
|
|
50
|
+
if (PRIVATE_IP_RE.test(host)) {
|
|
51
|
+
return { ok: false, reason: `Blocked private IP: ${host}` };
|
|
52
|
+
}
|
|
53
|
+
// 域名白名单(如果提供)
|
|
54
|
+
const allowed = opts?.allowedHosts ?? ALLOWED_CDN_HOSTS;
|
|
55
|
+
if (allowed.size > 0 && !allowed.has(host)) {
|
|
56
|
+
return { ok: false, reason: `Host not in allowlist: ${host}` };
|
|
57
|
+
}
|
|
58
|
+
return { ok: true };
|
|
59
|
+
}
|
|
60
|
+
// ── 文件名清洗 ────────────────────────────────────────────────────────────────
|
|
61
|
+
/**
|
|
62
|
+
* 清洗文件名:
|
|
63
|
+
* - 移除路径穿越字符(只保留 basename)
|
|
64
|
+
* - 替换非法字符
|
|
65
|
+
* - 空结果兜底
|
|
66
|
+
*/
|
|
67
|
+
export function sanitizeFileName(name) {
|
|
68
|
+
return path.basename(name).replace(/[<>:"|?*\x00-\x1f]/g, '_') || `file_${Date.now()}`;
|
|
69
|
+
}
|
|
70
|
+
// ── 图片验证 ──────────────────────────────────────────────────────────────────
|
|
71
|
+
/**
|
|
72
|
+
* 验证图片 Buffer:类型白名单 + 大小限制
|
|
73
|
+
* 返回检测到的 MIME 类型,验证失败返回 null + reason
|
|
74
|
+
*/
|
|
75
|
+
export async function validateImage(buffer, opts) {
|
|
76
|
+
const maxSize = opts?.maxSize ?? DEFAULT_MAX_IMAGE_SIZE;
|
|
77
|
+
const allowed = opts?.allowedMimes ?? ALLOWED_IMAGE_MIMES;
|
|
78
|
+
if (buffer.length === 0) {
|
|
79
|
+
return { mime: null, reason: 'Empty buffer' };
|
|
80
|
+
}
|
|
81
|
+
if (buffer.length > maxSize) {
|
|
82
|
+
return { mime: null, reason: `Image too large: ${buffer.length} bytes (max ${maxSize})` };
|
|
83
|
+
}
|
|
84
|
+
// 动态导入 image-type(ESM only)
|
|
85
|
+
const { default: imageType } = await import('image-type');
|
|
86
|
+
const type = await imageType(buffer);
|
|
87
|
+
if (!type) {
|
|
88
|
+
return { mime: null, reason: 'Unable to detect image type' };
|
|
89
|
+
}
|
|
90
|
+
if (!allowed.has(type.mime)) {
|
|
91
|
+
return { mime: null, reason: `Unsupported image type: ${type.mime}` };
|
|
92
|
+
}
|
|
93
|
+
return { mime: type.mime };
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* 保存 Buffer 到 uploads 目录
|
|
97
|
+
* - 自动创建目录
|
|
98
|
+
* - 文件名清洗
|
|
99
|
+
* - 同名文件自动添加时间戳后缀
|
|
100
|
+
*/
|
|
101
|
+
export function saveToUploads(buffer, rawFileName, projectPath, opts) {
|
|
102
|
+
const fileName = sanitizeFileName(rawFileName);
|
|
103
|
+
const uploadsDir = path.join(projectPath, UPLOADS_SUBDIR);
|
|
104
|
+
fs.mkdirSync(uploadsDir, { recursive: true });
|
|
105
|
+
let targetName = fileName;
|
|
106
|
+
const targetPath = path.join(uploadsDir, targetName);
|
|
107
|
+
// 去重:内容相同则复用
|
|
108
|
+
if (opts?.dedup !== false && fs.existsSync(targetPath)) {
|
|
109
|
+
const existingHash = hashFile(targetPath);
|
|
110
|
+
const newHash = crypto.createHash('md5').update(buffer).digest('hex');
|
|
111
|
+
if (existingHash === newHash) {
|
|
112
|
+
logger.debug(`[MediaCache] Dedup hit: ${targetName}`);
|
|
113
|
+
return { filePath: targetPath, fileName: targetName, size: buffer.length };
|
|
114
|
+
}
|
|
115
|
+
// 不同内容同名 → 加时间戳
|
|
116
|
+
const ext = path.extname(fileName);
|
|
117
|
+
const base = path.basename(fileName, ext);
|
|
118
|
+
targetName = `${base}_${Date.now()}${ext}`;
|
|
119
|
+
}
|
|
120
|
+
const finalPath = path.join(uploadsDir, targetName);
|
|
121
|
+
fs.writeFileSync(finalPath, buffer);
|
|
122
|
+
logger.info(`[MediaCache] Saved: ${finalPath} (${buffer.length} bytes)`);
|
|
123
|
+
return { filePath: finalPath, fileName: targetName, size: buffer.length };
|
|
124
|
+
}
|
|
125
|
+
function hashFile(filePath) {
|
|
126
|
+
const content = fs.readFileSync(filePath);
|
|
127
|
+
return crypto.createHash('md5').update(content).digest('hex');
|
|
128
|
+
}
|
|
129
|
+
/**
|
|
130
|
+
* 安全下载 URL 内容,带 SSRF 防护
|
|
131
|
+
*/
|
|
132
|
+
export async function safeFetch(url, opts) {
|
|
133
|
+
const maxSize = opts?.maxSize ?? DEFAULT_MAX_FILE_SIZE;
|
|
134
|
+
const timeout = opts?.timeout ?? 30_000;
|
|
135
|
+
// SSRF 检查
|
|
136
|
+
if (!opts?.skipSsrfCheck) {
|
|
137
|
+
const check = validateUrl(url, { allowedHosts: opts?.allowedHosts });
|
|
138
|
+
if (!check.ok) {
|
|
139
|
+
throw new Error(`SSRF blocked: ${check.reason}`);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
const controller = new AbortController();
|
|
143
|
+
const timer = setTimeout(() => controller.abort(), timeout);
|
|
144
|
+
try {
|
|
145
|
+
const res = await fetch(url, { signal: controller.signal });
|
|
146
|
+
if (!res.ok)
|
|
147
|
+
throw new Error(`Download failed: ${res.status} ${res.statusText}`);
|
|
148
|
+
// 检查 Content-Length(如果可用)
|
|
149
|
+
const contentLength = res.headers.get('content-length');
|
|
150
|
+
if (contentLength && parseInt(contentLength) > maxSize) {
|
|
151
|
+
throw new Error(`File too large: ${contentLength} bytes (max ${maxSize})`);
|
|
152
|
+
}
|
|
153
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
154
|
+
if (buffer.length > maxSize) {
|
|
155
|
+
throw new Error(`File too large: ${buffer.length} bytes (max ${maxSize})`);
|
|
156
|
+
}
|
|
157
|
+
return buffer;
|
|
158
|
+
}
|
|
159
|
+
finally {
|
|
160
|
+
clearTimeout(timer);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* 内存级下载缓存
|
|
165
|
+
* - 相同 key 不重复下载
|
|
166
|
+
* - TTL 过期自动清理
|
|
167
|
+
* - 最大条目限制防止内存泄漏
|
|
168
|
+
*/
|
|
169
|
+
export class DownloadCache {
|
|
170
|
+
cache = new Map();
|
|
171
|
+
ttlMs;
|
|
172
|
+
maxEntries;
|
|
173
|
+
constructor(opts) {
|
|
174
|
+
this.ttlMs = opts?.ttlMs ?? 5 * 60 * 1000; // 5 min
|
|
175
|
+
this.maxEntries = opts?.maxEntries ?? 100;
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* 获取或下载:缓存命中直接返回,否则执行 fetcher 并缓存结果
|
|
179
|
+
*/
|
|
180
|
+
async getOrFetch(key, fetcher) {
|
|
181
|
+
this.evict();
|
|
182
|
+
const cached = this.cache.get(key);
|
|
183
|
+
if (cached) {
|
|
184
|
+
logger.debug(`[DownloadCache] Hit: ${key}`);
|
|
185
|
+
return cached.buffer;
|
|
186
|
+
}
|
|
187
|
+
const buffer = await fetcher();
|
|
188
|
+
this.cache.set(key, { buffer, createdAt: Date.now() });
|
|
189
|
+
// 超过 maxEntries 时删最旧
|
|
190
|
+
if (this.cache.size > this.maxEntries) {
|
|
191
|
+
const oldest = this.cache.keys().next().value;
|
|
192
|
+
this.cache.delete(oldest);
|
|
193
|
+
}
|
|
194
|
+
return buffer;
|
|
195
|
+
}
|
|
196
|
+
evict() {
|
|
197
|
+
const now = Date.now();
|
|
198
|
+
for (const [key, entry] of this.cache) {
|
|
199
|
+
if (now - entry.createdAt > this.ttlMs) {
|
|
200
|
+
this.cache.delete(key);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
clear() {
|
|
205
|
+
this.cache.clear();
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -23,6 +23,12 @@ export class StatsCollector {
|
|
|
23
23
|
eventBus.subscribe('session:safe-mode-entered', (_event) => {
|
|
24
24
|
this.recordEvent({ type: 'safe-mode-entered', timestamp: Date.now() });
|
|
25
25
|
});
|
|
26
|
+
eventBus.subscribe('tool:result', (event) => {
|
|
27
|
+
const e = event;
|
|
28
|
+
if (e.isError) {
|
|
29
|
+
this.recordEvent({ type: 'tool-error', timestamp: Date.now(), toolName: e.toolName });
|
|
30
|
+
}
|
|
31
|
+
});
|
|
26
32
|
}
|
|
27
33
|
recordEvent(record) {
|
|
28
34
|
this.events.push(record);
|
|
@@ -40,6 +46,8 @@ export class StatsCollector {
|
|
|
40
46
|
let completed = 0;
|
|
41
47
|
let errors = 0;
|
|
42
48
|
const errorsByType = {};
|
|
49
|
+
let toolErrors = 0;
|
|
50
|
+
const toolErrorsByName = {};
|
|
43
51
|
let interrupts = 0;
|
|
44
52
|
let safeModeEntries = 0;
|
|
45
53
|
let totalDuration = 0;
|
|
@@ -62,6 +70,12 @@ export class StatsCollector {
|
|
|
62
70
|
errorsByType[event.errorType] = (errorsByType[event.errorType] || 0) + 1;
|
|
63
71
|
}
|
|
64
72
|
break;
|
|
73
|
+
case 'tool-error':
|
|
74
|
+
toolErrors++;
|
|
75
|
+
if (event.toolName) {
|
|
76
|
+
toolErrorsByName[event.toolName] = (toolErrorsByName[event.toolName] || 0) + 1;
|
|
77
|
+
}
|
|
78
|
+
break;
|
|
65
79
|
case 'interrupted':
|
|
66
80
|
interrupts++;
|
|
67
81
|
break;
|
|
@@ -77,6 +91,8 @@ export class StatsCollector {
|
|
|
77
91
|
completed,
|
|
78
92
|
errors,
|
|
79
93
|
errorsByType,
|
|
94
|
+
toolErrors,
|
|
95
|
+
toolErrorsByName,
|
|
80
96
|
interrupts,
|
|
81
97
|
safeModeEntries,
|
|
82
98
|
avgResponseMs: durationCount > 0 ? totalDuration / durationCount : 0
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "evolclaw",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.0",
|
|
4
4
|
"description": "Lightweight AI Agent gateway connecting Claude Agent SDK to messaging channels (Feishu, ACP) with multi-project session management",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -22,8 +22,8 @@
|
|
|
22
22
|
"prepublishOnly": "npm run build && npm test"
|
|
23
23
|
},
|
|
24
24
|
"dependencies": {
|
|
25
|
-
"@anthropic-ai/claude-agent-sdk": "^0.2.
|
|
26
|
-
"@aun
|
|
25
|
+
"@anthropic-ai/claude-agent-sdk": "^0.2.100",
|
|
26
|
+
"@eleans/aun-core-node": "^0.3.0",
|
|
27
27
|
"@larksuiteoapi/node-sdk": "^1.59.0",
|
|
28
28
|
"@openai/codex-sdk": "^0.118.0",
|
|
29
29
|
"image-type": "^6.0.0",
|