Update server.js
Browse files
server.js
CHANGED
|
@@ -24,6 +24,7 @@ const {
|
|
| 24 |
OPENWEBUI_API_KEY,
|
| 25 |
OPENCLAW_WS_URL,
|
| 26 |
OPENCLAW_TOKEN,
|
|
|
|
| 27 |
BOT_USERNAME = 'openman',
|
| 28 |
POLL_INTERVAL = 5000,
|
| 29 |
DEBUG_LOGS = 'true',
|
|
@@ -32,35 +33,15 @@ const {
|
|
| 32 |
MAX_UPLOAD_MB = '30',
|
| 33 |
DEFAULT_MESSAGE_LIMIT = '120',
|
| 34 |
PUBLIC_URL = '',
|
|
|
|
| 35 |
STORAGE_EXTERNAL_ENDPOINT,
|
| 36 |
STORAGE_PUBLIC_BUCKET,
|
| 37 |
STORAGE_REGION,
|
| 38 |
STORAGE_ACCESS_KEY_ID,
|
| 39 |
STORAGE_SECRET_ACCESS_KEY,
|
| 40 |
-
STORAGE_S3_FORCE_PATH_STYLE
|
| 41 |
-
|
| 42 |
-
// Optional STT(OpenAI-compatible) endpoint for voice transcription
|
| 43 |
-
STT_API_URL = '',
|
| 44 |
-
STT_API_KEY = '',
|
| 45 |
-
STT_MODEL = 'whisper-1',
|
| 46 |
-
STT_LANGUAGE = 'zh'
|
| 47 |
} = process.env;
|
| 48 |
|
| 49 |
-
const ALLOWED_UI_PROFILE_KEYS = [
|
| 50 |
-
'OPENCLAW_WS_URL',
|
| 51 |
-
'BOT_USERNAME',
|
| 52 |
-
'FRONT_STREAM_INTERVAL',
|
| 53 |
-
'MAX_UPLOAD_MB',
|
| 54 |
-
'DEFAULT_MESSAGE_LIMIT',
|
| 55 |
-
'DEBUG_LOGS',
|
| 56 |
-
'STORAGE_EXTERNAL_ENDPOINT',
|
| 57 |
-
'STORAGE_PUBLIC_BUCKET',
|
| 58 |
-
'STORAGE_REGION',
|
| 59 |
-
'STORAGE_ACCESS_KEY_ID',
|
| 60 |
-
'STORAGE_SECRET_ACCESS_KEY',
|
| 61 |
-
'STORAGE_S3_FORCE_PATH_STYLE'
|
| 62 |
-
];
|
| 63 |
-
|
| 64 |
const runtimeConfig = {
|
| 65 |
botUsername: BOT_USERNAME,
|
| 66 |
debugLogs: String(DEBUG_LOGS).toLowerCase() !== 'false',
|
|
@@ -68,16 +49,10 @@ const runtimeConfig = {
|
|
| 68 |
defaultMessageLimit: Math.max(parseInt(DEFAULT_MESSAGE_LIMIT, 10) || 120, 20),
|
| 69 |
uiProfile: {
|
| 70 |
OPENCLAW_WS_URL: OPENCLAW_WS_URL || '',
|
| 71 |
-
|
| 72 |
-
FRONT_STREAM_INTERVAL: FRONT_STREAM_INTERVAL || '',
|
| 73 |
-
MAX_UPLOAD_MB: MAX_UPLOAD_MB || '',
|
| 74 |
-
DEFAULT_MESSAGE_LIMIT: DEFAULT_MESSAGE_LIMIT || '',
|
| 75 |
-
DEBUG_LOGS: DEBUG_LOGS || '',
|
| 76 |
STORAGE_EXTERNAL_ENDPOINT: STORAGE_EXTERNAL_ENDPOINT || '',
|
| 77 |
STORAGE_PUBLIC_BUCKET: STORAGE_PUBLIC_BUCKET || '',
|
| 78 |
STORAGE_REGION: STORAGE_REGION || '',
|
| 79 |
-
STORAGE_ACCESS_KEY_ID: STORAGE_ACCESS_KEY_ID ? '***' : '',
|
| 80 |
-
STORAGE_SECRET_ACCESS_KEY: STORAGE_SECRET_ACCESS_KEY ? '***' : '',
|
| 81 |
STORAGE_S3_FORCE_PATH_STYLE: STORAGE_S3_FORCE_PATH_STYLE || 'false'
|
| 82 |
}
|
| 83 |
};
|
|
@@ -90,14 +65,14 @@ const s3 = new S3Client({
|
|
| 90 |
region: STORAGE_REGION || 'us-east-1',
|
| 91 |
endpoint: STORAGE_EXTERNAL_ENDPOINT,
|
| 92 |
forcePathStyle: STORAGE_S3_FORCE_PATH_STYLE === 'true',
|
| 93 |
-
credentials:
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
|
|
|
| 99 |
});
|
| 100 |
-
|
| 101 |
const S3_PREFIX_CONFIG = 'openclaw/channels.json';
|
| 102 |
const S3_PREFIX_UPLOADS = 'openclaw/uploads/';
|
| 103 |
|
|
@@ -135,7 +110,6 @@ const webuiApi = axios.create({
|
|
| 135 |
},
|
| 136 |
timeout: 10000
|
| 137 |
});
|
| 138 |
-
|
| 139 |
const fileDownloader = axios.create({
|
| 140 |
baseURL: OPENWEBUI_BASE_URL,
|
| 141 |
headers: { Authorization: `Bearer ${OPENWEBUI_API_KEY}` },
|
|
@@ -149,7 +123,7 @@ const fileDownloader = axios.create({
|
|
| 149 |
async function streamToString(stream) {
|
| 150 |
return new Promise((resolve, reject) => {
|
| 151 |
const chunks = [];
|
| 152 |
-
stream.on('data',
|
| 153 |
stream.on('error', reject);
|
| 154 |
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
| 155 |
});
|
|
@@ -167,7 +141,7 @@ async function loadChannelsFromS3() {
|
|
| 167 |
const str = await streamToString(rsp.Body);
|
| 168 |
const data = JSON.parse(str);
|
| 169 |
if (data && Array.isArray(data.channels)) {
|
| 170 |
-
data.channels.forEach(
|
| 171 |
if (!channelRuntime.has(ch.id)) {
|
| 172 |
channelRuntime.set(ch.id, { id: ch.id, name: ch.name, lastMsgId: null, syncing: false });
|
| 173 |
initChannelBaseline(ch.id);
|
|
@@ -287,6 +261,7 @@ function parseClawContent(content) {
|
|
| 287 |
lines.push(``);
|
| 288 |
result.files.push({ url: item.url, type: 'image' });
|
| 289 |
} else if (type === 'audio' && item.url) {
|
|
|
|
| 290 |
lines.push(`[语音](${item.url}#audio)`);
|
| 291 |
result.files.push({ url: item.url, type: 'audio' });
|
| 292 |
} else if (type === 'video' && item.url) {
|
|
@@ -353,10 +328,7 @@ function normalizeClawMessages(rawList = []) {
|
|
| 353 |
async function getClawMessagesByChannel(cid, limit = 120) {
|
| 354 |
const payload = await clawRequest(
|
| 355 |
'chat.history',
|
| 356 |
-
{
|
| 357 |
-
sessionKey: `agent:main:${cid}`,
|
| 358 |
-
limit: Math.max(10, limit)
|
| 359 |
-
},
|
| 360 |
7000
|
| 361 |
);
|
| 362 |
const raw = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
@@ -377,13 +349,11 @@ function passOK(req) {
|
|
| 377 |
const p = req.headers['x-connect-password'] || req.query.p || '';
|
| 378 |
return String(p) === String(CONNECT_PASSWORD);
|
| 379 |
}
|
| 380 |
-
|
| 381 |
function apiGuard(req, res, next) {
|
| 382 |
if (req.path === '/config') return next();
|
| 383 |
if (passOK(req)) return next();
|
| 384 |
return res.status(401).json({ error: '连接密码错误或未提供' });
|
| 385 |
}
|
| 386 |
-
|
| 387 |
app.use('/api', apiGuard);
|
| 388 |
|
| 389 |
app.get('/api/config', (req, res) => {
|
|
@@ -391,10 +361,6 @@ app.get('/api/config', (req, res) => {
|
|
| 391 |
needPassword: !!CONNECT_PASSWORD,
|
| 392 |
maxUploadMB: uploadMaxMB,
|
| 393 |
runtimeConfig,
|
| 394 |
-
stt: {
|
| 395 |
-
enabled: !!STT_API_URL,
|
| 396 |
-
model: STT_MODEL
|
| 397 |
-
},
|
| 398 |
envKeys: [
|
| 399 |
'CONNECT_PASSWORD',
|
| 400 |
'OPENCLAW_WS_URL',
|
|
@@ -408,42 +374,24 @@ app.get('/api/config', (req, res) => {
|
|
| 408 |
'STORAGE_REGION',
|
| 409 |
'STORAGE_ACCESS_KEY_ID',
|
| 410 |
'STORAGE_SECRET_ACCESS_KEY',
|
| 411 |
-
'STORAGE_S3_FORCE_PATH_STYLE'
|
|
|
|
| 412 |
]
|
| 413 |
});
|
| 414 |
});
|
| 415 |
|
| 416 |
-
app.get('/api/runtime-settings', (req, res) =>
|
| 417 |
-
res.json(runtimeConfig);
|
| 418 |
-
});
|
| 419 |
|
| 420 |
app.post('/api/runtime-settings', (req, res) => {
|
| 421 |
const body = req.body || {};
|
| 422 |
-
|
| 423 |
-
if (typeof body.
|
| 424 |
-
|
| 425 |
-
|
| 426 |
-
if (typeof body.debugLogs === 'boolean') {
|
| 427 |
-
runtimeConfig.debugLogs = body.debugLogs;
|
| 428 |
-
}
|
| 429 |
-
if (Number.isFinite(Number(body.streamInterval))) {
|
| 430 |
-
runtimeConfig.streamInterval = Math.max(800, parseInt(body.streamInterval, 10));
|
| 431 |
-
}
|
| 432 |
-
if (Number.isFinite(Number(body.defaultMessageLimit))) {
|
| 433 |
-
runtimeConfig.defaultMessageLimit = Math.max(20, parseInt(body.defaultMessageLimit, 10));
|
| 434 |
-
}
|
| 435 |
-
|
| 436 |
if (body.uiProfile && typeof body.uiProfile === 'object') {
|
| 437 |
-
|
| 438 |
-
for (const k of ALLOWED_UI_PROFILE_KEYS) {
|
| 439 |
-
if (Object.prototype.hasOwnProperty.call(body.uiProfile, k)) {
|
| 440 |
-
nextProfile[k] = String(body.uiProfile[k] ?? '');
|
| 441 |
-
}
|
| 442 |
-
}
|
| 443 |
-
runtimeConfig.uiProfile = nextProfile;
|
| 444 |
}
|
| 445 |
-
|
| 446 |
-
log(`⚙️ 运行设置更新: ${JSON.stringify({ ...runtimeConfig, uiProfile: '[omitted]' })}`);
|
| 447 |
res.json({ success: true, runtimeConfig });
|
| 448 |
});
|
| 449 |
|
|
@@ -451,17 +399,10 @@ app.get('/api/channels', (req, res) => {
|
|
| 451 |
res.json(Array.from(channelRuntime.values()).map(({ id, name }) => ({ id, name })));
|
| 452 |
});
|
| 453 |
|
| 454 |
-
app.post('/api/channels', (req, res) => {
|
| 455 |
-
|
| 456 |
-
});
|
| 457 |
-
|
| 458 |
-
app.delete('/api/channels/:id', (req, res) => {
|
| 459 |
-
res.status(403).json({ error: '频道由S3同步,禁止删除' });
|
| 460 |
-
});
|
| 461 |
-
|
| 462 |
app.get('/api/logs', (req, res) => res.json(logBuffer));
|
| 463 |
|
| 464 |
-
// Front messages from OpenClaw
|
| 465 |
app.get('/api/messages/:cid', async (req, res) => {
|
| 466 |
const cid = req.params.cid;
|
| 467 |
try {
|
|
@@ -474,12 +415,10 @@ app.get('/api/messages/:cid', async (req, res) => {
|
|
| 474 |
}
|
| 475 |
});
|
| 476 |
|
| 477 |
-
// SSE stream
|
| 478 |
app.get('/api/messages/:cid/stream', async (req, res) => {
|
| 479 |
const cid = req.params.cid;
|
| 480 |
const limit = Math.max(parseInt(req.query.limit, 10) || runtimeConfig.defaultMessageLimit, 20);
|
| 481 |
const interval = Math.max(parseInt(req.query.interval, 10) || runtimeConfig.streamInterval, 800);
|
| 482 |
-
|
| 483 |
if (!passOK(req)) return res.status(401).end('unauthorized');
|
| 484 |
|
| 485 |
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
@@ -512,7 +451,7 @@ app.get('/api/messages/:cid/stream', async (req, res) => {
|
|
| 512 |
return;
|
| 513 |
}
|
| 514 |
|
| 515 |
-
const idx = sorted.findIndex(
|
| 516 |
const fresh = idx === -1 ? sorted : sorted.slice(idx + 1);
|
| 517 |
|
| 518 |
if (fresh.length > 0) {
|
|
@@ -531,15 +470,14 @@ app.get('/api/messages/:cid/stream', async (req, res) => {
|
|
| 531 |
closed = true;
|
| 532 |
clearInterval(timer);
|
| 533 |
clearInterval(heartbeat);
|
| 534 |
-
try {
|
| 535 |
-
res.end();
|
| 536 |
-
} catch {}
|
| 537 |
});
|
| 538 |
});
|
| 539 |
|
| 540 |
app.post('/api/upload', async (req, res) => {
|
| 541 |
const { filename, base64, mimeType } = req.body || {};
|
| 542 |
if (!base64 || !filename) return res.status(400).json({ error: '参数缺失' });
|
|
|
|
| 543 |
try {
|
| 544 |
const buffer = Buffer.from(base64, 'base64');
|
| 545 |
if (buffer.length > uploadMaxBytes) {
|
|
@@ -554,53 +492,6 @@ app.post('/api/upload', async (req, res) => {
|
|
| 554 |
}
|
| 555 |
});
|
| 556 |
|
| 557 |
-
// Voice transcription (OpenAI-compatible STT API, optional)
|
| 558 |
-
app.post('/api/transcribe', async (req, res) => {
|
| 559 |
-
try {
|
| 560 |
-
if (!STT_API_URL) return res.json({ enabled: false, text: '' });
|
| 561 |
-
|
| 562 |
-
const { filename = 'voice.ogg', base64, mimeType = 'audio/ogg' } = req.body || {};
|
| 563 |
-
if (!base64) return res.status(400).json({ error: '缺少base64' });
|
| 564 |
-
|
| 565 |
-
const buffer = Buffer.from(base64, 'base64');
|
| 566 |
-
if (buffer.length > uploadMaxBytes) {
|
| 567 |
-
return res.status(413).json({ error: `音频过大,超过 ${uploadMaxMB}MB` });
|
| 568 |
-
}
|
| 569 |
-
|
| 570 |
-
const form = new FormData();
|
| 571 |
-
form.append('file', new Blob([buffer], { type: mimeType }), filename);
|
| 572 |
-
form.append('model', STT_MODEL);
|
| 573 |
-
if (STT_LANGUAGE) form.append('language', STT_LANGUAGE);
|
| 574 |
-
|
| 575 |
-
const headers = {};
|
| 576 |
-
if (STT_API_KEY) headers.Authorization = `Bearer ${STT_API_KEY}`;
|
| 577 |
-
|
| 578 |
-
const r = await fetch(STT_API_URL, {
|
| 579 |
-
method: 'POST',
|
| 580 |
-
headers,
|
| 581 |
-
body: form
|
| 582 |
-
});
|
| 583 |
-
|
| 584 |
-
const txt = await r.text();
|
| 585 |
-
let data = {};
|
| 586 |
-
try {
|
| 587 |
-
data = JSON.parse(txt);
|
| 588 |
-
} catch {
|
| 589 |
-
data = { text: txt };
|
| 590 |
-
}
|
| 591 |
-
|
| 592 |
-
if (!r.ok) {
|
| 593 |
-
return res.status(500).json({ error: data.error?.message || data.error || '转写失败' });
|
| 594 |
-
}
|
| 595 |
-
|
| 596 |
-
const text = data.text || data.result?.text || '';
|
| 597 |
-
return res.json({ enabled: true, text: String(text || '').trim() });
|
| 598 |
-
} catch (e) {
|
| 599 |
-
err(`/api/transcribe失败: ${e.message}`);
|
| 600 |
-
return res.status(500).json({ error: '转写异常' });
|
| 601 |
-
}
|
| 602 |
-
});
|
| 603 |
-
|
| 604 |
app.post('/api/send/:cid', async (req, res) => {
|
| 605 |
const cid = req.params.cid;
|
| 606 |
const { text = '' } = req.body || {};
|
|
@@ -619,9 +510,7 @@ app.get('/health', (req, res) => {
|
|
| 619 |
res.json({ status: 'ok', wsConnected: isClawReady, channels: channelRuntime.size });
|
| 620 |
});
|
| 621 |
|
| 622 |
-
app.get('/', (req, res) =>
|
| 623 |
-
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
| 624 |
-
});
|
| 625 |
|
| 626 |
// ============================================================
|
| 627 |
// 4) OpenClaw WS + existing sync logic
|
|
@@ -663,19 +552,13 @@ const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
| 663 |
const rawPublicKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(12);
|
| 664 |
const deviceId = crypto.createHash('sha256').update(rawPublicKey).digest('hex');
|
| 665 |
const toB64Url = (buf) =>
|
| 666 |
-
buf
|
| 667 |
-
.toString('base64')
|
| 668 |
-
.replace(/\+/g, '-')
|
| 669 |
-
.replace(/\//g, '_')
|
| 670 |
-
.replace(/=+$/, '');
|
| 671 |
|
| 672 |
function cleanupConnection() {
|
| 673 |
if (pingInterval) clearInterval(pingInterval);
|
| 674 |
if (ws) {
|
| 675 |
ws.removeAllListeners();
|
| 676 |
-
try {
|
| 677 |
-
ws.terminate();
|
| 678 |
-
} catch {}
|
| 679 |
ws = null;
|
| 680 |
}
|
| 681 |
isClawReady = false;
|
|
@@ -683,6 +566,10 @@ function cleanupConnection() {
|
|
| 683 |
}
|
| 684 |
|
| 685 |
function connectOpenClaw(retryCount = 0) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 686 |
if (isReconnecting || ws) return;
|
| 687 |
isReconnecting = true;
|
| 688 |
log(`🔌 连接 OpenClaw (${retryCount + 1})...`);
|
|
@@ -694,10 +581,7 @@ function connectOpenClaw(retryCount = 0) {
|
|
| 694 |
log('✅ WS 握手成功');
|
| 695 |
isReconnecting = false;
|
| 696 |
ws.isAlive = true;
|
| 697 |
-
|
| 698 |
-
ws.on('pong', () => {
|
| 699 |
-
ws.isAlive = true;
|
| 700 |
-
});
|
| 701 |
|
| 702 |
pingInterval = setInterval(() => {
|
| 703 |
if (ws.isAlive === false) {
|
|
@@ -731,51 +615,39 @@ function handleDisconnect(retryCount) {
|
|
| 731 |
|
| 732 |
function handleClawMessage(data) {
|
| 733 |
let msg;
|
| 734 |
-
try {
|
| 735 |
-
msg = JSON.parse(data.toString());
|
| 736 |
-
} catch {
|
| 737 |
-
return;
|
| 738 |
-
}
|
| 739 |
|
| 740 |
if (msg.event === 'connect.challenge') {
|
| 741 |
const { nonce } = msg.payload;
|
| 742 |
const signedAt = Date.now();
|
| 743 |
const payload = [
|
| 744 |
-
'v2',
|
| 745 |
-
deviceId,
|
| 746 |
-
'openclaw-control-ui',
|
| 747 |
-
'webchat',
|
| 748 |
-
'operator',
|
| 749 |
'operator.admin,operator.approvals,operator.pairing',
|
| 750 |
-
String(signedAt),
|
| 751 |
-
OPENCLAW_TOKEN || '',
|
| 752 |
-
nonce
|
| 753 |
].join('|');
|
| 754 |
|
| 755 |
const signature = crypto.sign(null, Buffer.from(payload), privateKey);
|
| 756 |
|
| 757 |
-
ws.send(
|
| 758 |
-
|
| 759 |
-
|
| 760 |
-
|
| 761 |
-
|
| 762 |
-
|
| 763 |
-
|
| 764 |
-
|
| 765 |
-
|
| 766 |
-
|
| 767 |
-
|
| 768 |
-
|
| 769 |
-
|
| 770 |
-
|
| 771 |
-
|
| 772 |
-
|
| 773 |
-
|
| 774 |
-
|
| 775 |
-
|
| 776 |
-
|
| 777 |
-
})
|
| 778 |
-
);
|
| 779 |
}
|
| 780 |
|
| 781 |
if (msg.payload?.type === 'hello-ok') {
|
|
@@ -787,22 +659,13 @@ function handleClawMessage(data) {
|
|
| 787 |
const req = pendingRequests.get(msg.id);
|
| 788 |
pendingRequests.delete(msg.id);
|
| 789 |
if (req?.timer) clearTimeout(req.timer);
|
| 790 |
-
try {
|
| 791 |
-
req?.resolve?.(msg.payload);
|
| 792 |
-
} catch {}
|
| 793 |
}
|
| 794 |
}
|
| 795 |
|
| 796 |
-
async function fetchClawHistory(cid) {
|
| 797 |
try {
|
| 798 |
-
const payload = await clawRequest(
|
| 799 |
-
'chat.history',
|
| 800 |
-
{
|
| 801 |
-
sessionKey: `agent:main:${cid}`,
|
| 802 |
-
limit: 5
|
| 803 |
-
},
|
| 804 |
-
5000
|
| 805 |
-
);
|
| 806 |
return payload?.messages || [];
|
| 807 |
} catch {
|
| 808 |
return [];
|
|
@@ -812,25 +675,22 @@ async function fetchClawHistory(cid) {
|
|
| 812 |
function triggerAgent(cid, uid, name, text, isCommand) {
|
| 813 |
if (!isClawReady || !ws || ws.readyState !== WebSocket.OPEN) return;
|
| 814 |
|
| 815 |
-
const finalMsg = isCommand ? text : `${text}`;
|
| 816 |
-
ws.send(
|
| 817 |
-
|
| 818 |
-
|
| 819 |
-
|
| 820 |
-
|
| 821 |
-
|
| 822 |
-
|
| 823 |
-
|
| 824 |
-
|
| 825 |
-
|
| 826 |
-
|
| 827 |
-
})
|
| 828 |
-
);
|
| 829 |
-
|
| 830 |
log(`🚀 [发送] ${name}: ${text.slice(0, 30)}...`);
|
| 831 |
}
|
| 832 |
|
| 833 |
-
// 你原同步逻辑(保留)
|
| 834 |
async function syncChannel(cid) {
|
| 835 |
const state = channelRuntime.get(cid);
|
| 836 |
if (!state || !state.lastMsgId || !isClawReady) return;
|
|
@@ -849,31 +709,28 @@ async function syncChannel(cid) {
|
|
| 849 |
const { data: msgs } = await webuiApi.get(`/api/v1/channels/${cid}/messages?skip=0&limit=10`);
|
| 850 |
if (!msgs?.length) throw new Error('No messages');
|
| 851 |
|
| 852 |
-
const webuiFingerprints = new Set(msgs.map(
|
| 853 |
|
| 854 |
const activeUsers = new Map();
|
| 855 |
-
msgs.slice(0, 5).forEach(
|
| 856 |
if (m.user_id) activeUsers.set(m.user_id, { id: m.user_id, name: m.user?.name || 'User' });
|
| 857 |
});
|
| 858 |
|
| 859 |
for (const u of activeUsers.values()) {
|
| 860 |
-
await new Promise(
|
| 861 |
const history = await fetchClawHistory(cid, u.id);
|
| 862 |
-
const replies = history.filter(
|
|
|
|
| 863 |
for (const r of replies) {
|
| 864 |
const txt =
|
| 865 |
typeof r.content === 'string'
|
| 866 |
? r.content.trim()
|
| 867 |
: Array.isArray(r.content)
|
| 868 |
-
|
| 869 |
-
|
| 870 |
if (txt && !webuiFingerprints.has(txt)) {
|
| 871 |
log(`📥 [回填回复] -> ${u.name}`);
|
| 872 |
-
await webuiApi.post(`/api/v1/channels/${cid}/messages/post`, {
|
| 873 |
-
content: txt,
|
| 874 |
-
data: { files: [] },
|
| 875 |
-
reply_to_id: null
|
| 876 |
-
});
|
| 877 |
webuiFingerprints.add(txt);
|
| 878 |
}
|
| 879 |
}
|
|
@@ -881,13 +738,11 @@ async function syncChannel(cid) {
|
|
| 881 |
|
| 882 |
const sorted = msgs.reverse();
|
| 883 |
let newMsgs = [];
|
| 884 |
-
const startIdx = sorted.findIndex(
|
| 885 |
|
| 886 |
-
if (state.lastMsgId === 'EMPTY_START')
|
| 887 |
-
|
| 888 |
-
|
| 889 |
-
newMsgs = sorted.slice(startIdx + 1);
|
| 890 |
-
} else {
|
| 891 |
if (sorted.length > 0) {
|
| 892 |
const newestId = sorted[sorted.length - 1].id;
|
| 893 |
log(`⚠️ [${state.name}] 锚点丢失,重置 -> ${newestId}`);
|
|
@@ -925,9 +780,7 @@ async function syncChannel(cid) {
|
|
| 925 |
if (rawFiles.length > 0) {
|
| 926 |
for (const fItem of rawFiles) {
|
| 927 |
try {
|
| 928 |
-
let buffer;
|
| 929 |
-
let filename;
|
| 930 |
-
let mimeType;
|
| 931 |
const f = fItem.file || fItem;
|
| 932 |
|
| 933 |
if (fItem.url && fItem.url.startsWith('data:')) {
|
|
@@ -959,9 +812,7 @@ async function syncChannel(cid) {
|
|
| 959 |
const isImage = mimeType?.startsWith('image/');
|
| 960 |
const isAudio = mimeType?.startsWith('audio/');
|
| 961 |
atts.push(
|
| 962 |
-
`[${isImage ? '图片' : isAudio ? '语音' : '文件'}: ${filename}](${s3Url}${
|
| 963 |
-
isAudio ? '#audio' : ''
|
| 964 |
-
}) (必须下载附件,了解内容后才能回复)`
|
| 965 |
);
|
| 966 |
}
|
| 967 |
}
|
|
@@ -976,7 +827,7 @@ async function syncChannel(cid) {
|
|
| 976 |
const isCommand = txt.trim().startsWith('/');
|
| 977 |
if (txt || atts.length > 0) {
|
| 978 |
if (!txt && atts.length > 0) txt = '发送了附件。';
|
| 979 |
-
await new Promise(
|
| 980 |
triggerAgent(cid, m.user_id, sender, txt, isCommand);
|
| 981 |
}
|
| 982 |
}
|
|
@@ -993,7 +844,7 @@ async function loop() {
|
|
| 993 |
if (isClawReady) {
|
| 994 |
for (const cid of channelRuntime.keys()) {
|
| 995 |
await syncChannel(cid);
|
| 996 |
-
await new Promise(
|
| 997 |
}
|
| 998 |
}
|
| 999 |
setTimeout(loop, delay);
|
|
|
|
| 24 |
OPENWEBUI_API_KEY,
|
| 25 |
OPENCLAW_WS_URL,
|
| 26 |
OPENCLAW_TOKEN,
|
| 27 |
+
|
| 28 |
BOT_USERNAME = 'openman',
|
| 29 |
POLL_INTERVAL = 5000,
|
| 30 |
DEBUG_LOGS = 'true',
|
|
|
|
| 33 |
MAX_UPLOAD_MB = '30',
|
| 34 |
DEFAULT_MESSAGE_LIMIT = '120',
|
| 35 |
PUBLIC_URL = '',
|
| 36 |
+
|
| 37 |
STORAGE_EXTERNAL_ENDPOINT,
|
| 38 |
STORAGE_PUBLIC_BUCKET,
|
| 39 |
STORAGE_REGION,
|
| 40 |
STORAGE_ACCESS_KEY_ID,
|
| 41 |
STORAGE_SECRET_ACCESS_KEY,
|
| 42 |
+
STORAGE_S3_FORCE_PATH_STYLE
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
} = process.env;
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
const runtimeConfig = {
|
| 46 |
botUsername: BOT_USERNAME,
|
| 47 |
debugLogs: String(DEBUG_LOGS).toLowerCase() !== 'false',
|
|
|
|
| 49 |
defaultMessageLimit: Math.max(parseInt(DEFAULT_MESSAGE_LIMIT, 10) || 120, 20),
|
| 50 |
uiProfile: {
|
| 51 |
OPENCLAW_WS_URL: OPENCLAW_WS_URL || '',
|
| 52 |
+
PUBLIC_URL: PUBLIC_URL || '',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
STORAGE_EXTERNAL_ENDPOINT: STORAGE_EXTERNAL_ENDPOINT || '',
|
| 54 |
STORAGE_PUBLIC_BUCKET: STORAGE_PUBLIC_BUCKET || '',
|
| 55 |
STORAGE_REGION: STORAGE_REGION || '',
|
|
|
|
|
|
|
| 56 |
STORAGE_S3_FORCE_PATH_STYLE: STORAGE_S3_FORCE_PATH_STYLE || 'false'
|
| 57 |
}
|
| 58 |
};
|
|
|
|
| 65 |
region: STORAGE_REGION || 'us-east-1',
|
| 66 |
endpoint: STORAGE_EXTERNAL_ENDPOINT,
|
| 67 |
forcePathStyle: STORAGE_S3_FORCE_PATH_STYLE === 'true',
|
| 68 |
+
credentials:
|
| 69 |
+
STORAGE_ACCESS_KEY_ID && STORAGE_SECRET_ACCESS_KEY
|
| 70 |
+
? {
|
| 71 |
+
accessKeyId: STORAGE_ACCESS_KEY_ID,
|
| 72 |
+
secretAccessKey: STORAGE_SECRET_ACCESS_KEY
|
| 73 |
+
}
|
| 74 |
+
: undefined
|
| 75 |
});
|
|
|
|
| 76 |
const S3_PREFIX_CONFIG = 'openclaw/channels.json';
|
| 77 |
const S3_PREFIX_UPLOADS = 'openclaw/uploads/';
|
| 78 |
|
|
|
|
| 110 |
},
|
| 111 |
timeout: 10000
|
| 112 |
});
|
|
|
|
| 113 |
const fileDownloader = axios.create({
|
| 114 |
baseURL: OPENWEBUI_BASE_URL,
|
| 115 |
headers: { Authorization: `Bearer ${OPENWEBUI_API_KEY}` },
|
|
|
|
| 123 |
async function streamToString(stream) {
|
| 124 |
return new Promise((resolve, reject) => {
|
| 125 |
const chunks = [];
|
| 126 |
+
stream.on('data', c => chunks.push(c));
|
| 127 |
stream.on('error', reject);
|
| 128 |
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
|
| 129 |
});
|
|
|
|
| 141 |
const str = await streamToString(rsp.Body);
|
| 142 |
const data = JSON.parse(str);
|
| 143 |
if (data && Array.isArray(data.channels)) {
|
| 144 |
+
data.channels.forEach(ch => {
|
| 145 |
if (!channelRuntime.has(ch.id)) {
|
| 146 |
channelRuntime.set(ch.id, { id: ch.id, name: ch.name, lastMsgId: null, syncing: false });
|
| 147 |
initChannelBaseline(ch.id);
|
|
|
|
| 261 |
lines.push(``);
|
| 262 |
result.files.push({ url: item.url, type: 'image' });
|
| 263 |
} else if (type === 'audio' && item.url) {
|
| 264 |
+
// 用 #audio 明确标记,前端不会误判成 video
|
| 265 |
lines.push(`[语音](${item.url}#audio)`);
|
| 266 |
result.files.push({ url: item.url, type: 'audio' });
|
| 267 |
} else if (type === 'video' && item.url) {
|
|
|
|
| 328 |
async function getClawMessagesByChannel(cid, limit = 120) {
|
| 329 |
const payload = await clawRequest(
|
| 330 |
'chat.history',
|
| 331 |
+
{ sessionKey: `agent:main:${cid}`, limit: Math.max(10, limit) },
|
|
|
|
|
|
|
|
|
|
| 332 |
7000
|
| 333 |
);
|
| 334 |
const raw = Array.isArray(payload?.messages) ? payload.messages : [];
|
|
|
|
| 349 |
const p = req.headers['x-connect-password'] || req.query.p || '';
|
| 350 |
return String(p) === String(CONNECT_PASSWORD);
|
| 351 |
}
|
|
|
|
| 352 |
function apiGuard(req, res, next) {
|
| 353 |
if (req.path === '/config') return next();
|
| 354 |
if (passOK(req)) return next();
|
| 355 |
return res.status(401).json({ error: '连接密码错误或未提供' });
|
| 356 |
}
|
|
|
|
| 357 |
app.use('/api', apiGuard);
|
| 358 |
|
| 359 |
app.get('/api/config', (req, res) => {
|
|
|
|
| 361 |
needPassword: !!CONNECT_PASSWORD,
|
| 362 |
maxUploadMB: uploadMaxMB,
|
| 363 |
runtimeConfig,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 364 |
envKeys: [
|
| 365 |
'CONNECT_PASSWORD',
|
| 366 |
'OPENCLAW_WS_URL',
|
|
|
|
| 374 |
'STORAGE_REGION',
|
| 375 |
'STORAGE_ACCESS_KEY_ID',
|
| 376 |
'STORAGE_SECRET_ACCESS_KEY',
|
| 377 |
+
'STORAGE_S3_FORCE_PATH_STYLE',
|
| 378 |
+
'PUBLIC_URL'
|
| 379 |
]
|
| 380 |
});
|
| 381 |
});
|
| 382 |
|
| 383 |
+
app.get('/api/runtime-settings', (req, res) => res.json(runtimeConfig));
|
|
|
|
|
|
|
| 384 |
|
| 385 |
app.post('/api/runtime-settings', (req, res) => {
|
| 386 |
const body = req.body || {};
|
| 387 |
+
if (typeof body.botUsername === 'string' && body.botUsername.trim()) runtimeConfig.botUsername = body.botUsername.trim();
|
| 388 |
+
if (typeof body.debugLogs === 'boolean') runtimeConfig.debugLogs = body.debugLogs;
|
| 389 |
+
if (Number.isFinite(Number(body.streamInterval))) runtimeConfig.streamInterval = Math.max(800, parseInt(body.streamInterval, 10));
|
| 390 |
+
if (Number.isFinite(Number(body.defaultMessageLimit))) runtimeConfig.defaultMessageLimit = Math.max(20, parseInt(body.defaultMessageLimit, 10));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 391 |
if (body.uiProfile && typeof body.uiProfile === 'object') {
|
| 392 |
+
runtimeConfig.uiProfile = { ...runtimeConfig.uiProfile, ...body.uiProfile };
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
}
|
| 394 |
+
log(`⚙️ 运行设置更新`);
|
|
|
|
| 395 |
res.json({ success: true, runtimeConfig });
|
| 396 |
});
|
| 397 |
|
|
|
|
| 399 |
res.json(Array.from(channelRuntime.values()).map(({ id, name }) => ({ id, name })));
|
| 400 |
});
|
| 401 |
|
| 402 |
+
app.post('/api/channels', (req, res) => res.status(403).json({ error: '频道由S3同步,禁止新增' }));
|
| 403 |
+
app.delete('/api/channels/:id', (req, res) => res.status(403).json({ error: '频道由S3同步,禁止删除' }));
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
app.get('/api/logs', (req, res) => res.json(logBuffer));
|
| 405 |
|
|
|
|
| 406 |
app.get('/api/messages/:cid', async (req, res) => {
|
| 407 |
const cid = req.params.cid;
|
| 408 |
try {
|
|
|
|
| 415 |
}
|
| 416 |
});
|
| 417 |
|
|
|
|
| 418 |
app.get('/api/messages/:cid/stream', async (req, res) => {
|
| 419 |
const cid = req.params.cid;
|
| 420 |
const limit = Math.max(parseInt(req.query.limit, 10) || runtimeConfig.defaultMessageLimit, 20);
|
| 421 |
const interval = Math.max(parseInt(req.query.interval, 10) || runtimeConfig.streamInterval, 800);
|
|
|
|
| 422 |
if (!passOK(req)) return res.status(401).end('unauthorized');
|
| 423 |
|
| 424 |
res.setHeader('Content-Type', 'text/event-stream; charset=utf-8');
|
|
|
|
| 451 |
return;
|
| 452 |
}
|
| 453 |
|
| 454 |
+
const idx = sorted.findIndex(m => m.id === lastSeen);
|
| 455 |
const fresh = idx === -1 ? sorted : sorted.slice(idx + 1);
|
| 456 |
|
| 457 |
if (fresh.length > 0) {
|
|
|
|
| 470 |
closed = true;
|
| 471 |
clearInterval(timer);
|
| 472 |
clearInterval(heartbeat);
|
| 473 |
+
try { res.end(); } catch {}
|
|
|
|
|
|
|
| 474 |
});
|
| 475 |
});
|
| 476 |
|
| 477 |
app.post('/api/upload', async (req, res) => {
|
| 478 |
const { filename, base64, mimeType } = req.body || {};
|
| 479 |
if (!base64 || !filename) return res.status(400).json({ error: '参数缺失' });
|
| 480 |
+
|
| 481 |
try {
|
| 482 |
const buffer = Buffer.from(base64, 'base64');
|
| 483 |
if (buffer.length > uploadMaxBytes) {
|
|
|
|
| 492 |
}
|
| 493 |
});
|
| 494 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 495 |
app.post('/api/send/:cid', async (req, res) => {
|
| 496 |
const cid = req.params.cid;
|
| 497 |
const { text = '' } = req.body || {};
|
|
|
|
| 510 |
res.json({ status: 'ok', wsConnected: isClawReady, channels: channelRuntime.size });
|
| 511 |
});
|
| 512 |
|
| 513 |
+
app.get('/', (req, res) => res.sendFile(path.join(__dirname, 'public', 'index.html')));
|
|
|
|
|
|
|
| 514 |
|
| 515 |
// ============================================================
|
| 516 |
// 4) OpenClaw WS + existing sync logic
|
|
|
|
| 552 |
const rawPublicKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(12);
|
| 553 |
const deviceId = crypto.createHash('sha256').update(rawPublicKey).digest('hex');
|
| 554 |
const toB64Url = (buf) =>
|
| 555 |
+
buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
|
|
|
|
|
|
|
|
|
|
|
|
|
| 556 |
|
| 557 |
function cleanupConnection() {
|
| 558 |
if (pingInterval) clearInterval(pingInterval);
|
| 559 |
if (ws) {
|
| 560 |
ws.removeAllListeners();
|
| 561 |
+
try { ws.terminate(); } catch {}
|
|
|
|
|
|
|
| 562 |
ws = null;
|
| 563 |
}
|
| 564 |
isClawReady = false;
|
|
|
|
| 566 |
}
|
| 567 |
|
| 568 |
function connectOpenClaw(retryCount = 0) {
|
| 569 |
+
if (!OPENCLAW_WS_URL) {
|
| 570 |
+
err('OPENCLAW_WS_URL 未配置,无法连接 OpenClaw');
|
| 571 |
+
return;
|
| 572 |
+
}
|
| 573 |
if (isReconnecting || ws) return;
|
| 574 |
isReconnecting = true;
|
| 575 |
log(`🔌 连接 OpenClaw (${retryCount + 1})...`);
|
|
|
|
| 581 |
log('✅ WS 握手成功');
|
| 582 |
isReconnecting = false;
|
| 583 |
ws.isAlive = true;
|
| 584 |
+
ws.on('pong', () => { ws.isAlive = true; });
|
|
|
|
|
|
|
|
|
|
| 585 |
|
| 586 |
pingInterval = setInterval(() => {
|
| 587 |
if (ws.isAlive === false) {
|
|
|
|
| 615 |
|
| 616 |
function handleClawMessage(data) {
|
| 617 |
let msg;
|
| 618 |
+
try { msg = JSON.parse(data.toString()); } catch { return; }
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
|
| 620 |
if (msg.event === 'connect.challenge') {
|
| 621 |
const { nonce } = msg.payload;
|
| 622 |
const signedAt = Date.now();
|
| 623 |
const payload = [
|
| 624 |
+
'v2', deviceId, 'openclaw-control-ui', 'webchat', 'operator',
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
'operator.admin,operator.approvals,operator.pairing',
|
| 626 |
+
String(signedAt), OPENCLAW_TOKEN || '', nonce
|
|
|
|
|
|
|
| 627 |
].join('|');
|
| 628 |
|
| 629 |
const signature = crypto.sign(null, Buffer.from(payload), privateKey);
|
| 630 |
|
| 631 |
+
ws.send(JSON.stringify({
|
| 632 |
+
type: 'req',
|
| 633 |
+
id: crypto.randomUUID(),
|
| 634 |
+
method: 'connect',
|
| 635 |
+
params: {
|
| 636 |
+
minProtocol: 3,
|
| 637 |
+
maxProtocol: 3,
|
| 638 |
+
client: { id: 'openclaw-control-ui', version: 'dev', platform: 'Win32', mode: 'webchat' },
|
| 639 |
+
device: {
|
| 640 |
+
id: deviceId,
|
| 641 |
+
publicKey: toB64Url(rawPublicKey),
|
| 642 |
+
signature: toB64Url(signature),
|
| 643 |
+
signedAt,
|
| 644 |
+
nonce
|
| 645 |
+
},
|
| 646 |
+
auth: { token: OPENCLAW_TOKEN },
|
| 647 |
+
role: 'operator',
|
| 648 |
+
scopes: ['operator.admin', 'operator.approvals', 'operator.pairing']
|
| 649 |
+
}
|
| 650 |
+
}));
|
|
|
|
|
|
|
| 651 |
}
|
| 652 |
|
| 653 |
if (msg.payload?.type === 'hello-ok') {
|
|
|
|
| 659 |
const req = pendingRequests.get(msg.id);
|
| 660 |
pendingRequests.delete(msg.id);
|
| 661 |
if (req?.timer) clearTimeout(req.timer);
|
| 662 |
+
try { req?.resolve?.(msg.payload); } catch {}
|
|
|
|
|
|
|
| 663 |
}
|
| 664 |
}
|
| 665 |
|
| 666 |
+
async function fetchClawHistory(cid, uid) { // uid 保留兼容旧调用
|
| 667 |
try {
|
| 668 |
+
const payload = await clawRequest('chat.history', { sessionKey: `agent:main:${cid}`, limit: 5 }, 5000);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 669 |
return payload?.messages || [];
|
| 670 |
} catch {
|
| 671 |
return [];
|
|
|
|
| 675 |
function triggerAgent(cid, uid, name, text, isCommand) {
|
| 676 |
if (!isClawReady || !ws || ws.readyState !== WebSocket.OPEN) return;
|
| 677 |
|
| 678 |
+
const finalMsg = isCommand ? text : `${name} 说: ${text}`;
|
| 679 |
+
ws.send(JSON.stringify({
|
| 680 |
+
type: 'req',
|
| 681 |
+
id: crypto.randomUUID(),
|
| 682 |
+
method: isCommand ? 'chat.send' : 'agent',
|
| 683 |
+
params: {
|
| 684 |
+
sessionKey: `agent:main:${cid}`,
|
| 685 |
+
message: finalMsg,
|
| 686 |
+
idempotencyKey: crypto.randomUUID(),
|
| 687 |
+
deliver: isCommand ? false : undefined
|
| 688 |
+
}
|
| 689 |
+
}));
|
|
|
|
|
|
|
|
|
|
| 690 |
log(`🚀 [发送] ${name}: ${text.slice(0, 30)}...`);
|
| 691 |
}
|
| 692 |
|
| 693 |
+
// 你原同步逻辑(保留,未做大改动)
|
| 694 |
async function syncChannel(cid) {
|
| 695 |
const state = channelRuntime.get(cid);
|
| 696 |
if (!state || !state.lastMsgId || !isClawReady) return;
|
|
|
|
| 709 |
const { data: msgs } = await webuiApi.get(`/api/v1/channels/${cid}/messages?skip=0&limit=10`);
|
| 710 |
if (!msgs?.length) throw new Error('No messages');
|
| 711 |
|
| 712 |
+
const webuiFingerprints = new Set(msgs.map(m => (m.content || m.data?.content || '').trim()).filter(Boolean));
|
| 713 |
|
| 714 |
const activeUsers = new Map();
|
| 715 |
+
msgs.slice(0, 5).forEach(m => {
|
| 716 |
if (m.user_id) activeUsers.set(m.user_id, { id: m.user_id, name: m.user?.name || 'User' });
|
| 717 |
});
|
| 718 |
|
| 719 |
for (const u of activeUsers.values()) {
|
| 720 |
+
await new Promise(r => setTimeout(r, 200));
|
| 721 |
const history = await fetchClawHistory(cid, u.id);
|
| 722 |
+
const replies = history.filter(h => h.role === 'assistant').slice(-2);
|
| 723 |
+
|
| 724 |
for (const r of replies) {
|
| 725 |
const txt =
|
| 726 |
typeof r.content === 'string'
|
| 727 |
? r.content.trim()
|
| 728 |
: Array.isArray(r.content)
|
| 729 |
+
? (r.content[0]?.text || '').trim()
|
| 730 |
+
: '';
|
| 731 |
if (txt && !webuiFingerprints.has(txt)) {
|
| 732 |
log(`📥 [回填回复] -> ${u.name}`);
|
| 733 |
+
await webuiApi.post(`/api/v1/channels/${cid}/messages/post`, { content: txt, data: { files: [] }, reply_to_id: null });
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
webuiFingerprints.add(txt);
|
| 735 |
}
|
| 736 |
}
|
|
|
|
| 738 |
|
| 739 |
const sorted = msgs.reverse();
|
| 740 |
let newMsgs = [];
|
| 741 |
+
const startIdx = sorted.findIndex(m => m.id === state.lastMsgId);
|
| 742 |
|
| 743 |
+
if (state.lastMsgId === 'EMPTY_START') newMsgs = sorted;
|
| 744 |
+
else if (startIdx !== -1) newMsgs = sorted.slice(startIdx + 1);
|
| 745 |
+
else {
|
|
|
|
|
|
|
| 746 |
if (sorted.length > 0) {
|
| 747 |
const newestId = sorted[sorted.length - 1].id;
|
| 748 |
log(`⚠️ [${state.name}] 锚点丢失,重置 -> ${newestId}`);
|
|
|
|
| 780 |
if (rawFiles.length > 0) {
|
| 781 |
for (const fItem of rawFiles) {
|
| 782 |
try {
|
| 783 |
+
let buffer, filename, mimeType;
|
|
|
|
|
|
|
| 784 |
const f = fItem.file || fItem;
|
| 785 |
|
| 786 |
if (fItem.url && fItem.url.startsWith('data:')) {
|
|
|
|
| 812 |
const isImage = mimeType?.startsWith('image/');
|
| 813 |
const isAudio = mimeType?.startsWith('audio/');
|
| 814 |
atts.push(
|
| 815 |
+
`[${isImage ? '图片' : isAudio ? '语音' : '文件'}: ${filename}](${s3Url}${isAudio ? '#audio' : ''}) (必须下载附件,了解内容后才能回复)`
|
|
|
|
|
|
|
| 816 |
);
|
| 817 |
}
|
| 818 |
}
|
|
|
|
| 827 |
const isCommand = txt.trim().startsWith('/');
|
| 828 |
if (txt || atts.length > 0) {
|
| 829 |
if (!txt && atts.length > 0) txt = '发送了附件。';
|
| 830 |
+
await new Promise(r => setTimeout(r, 500));
|
| 831 |
triggerAgent(cid, m.user_id, sender, txt, isCommand);
|
| 832 |
}
|
| 833 |
}
|
|
|
|
| 844 |
if (isClawReady) {
|
| 845 |
for (const cid of channelRuntime.keys()) {
|
| 846 |
await syncChannel(cid);
|
| 847 |
+
await new Promise(r => setTimeout(r, 200));
|
| 848 |
}
|
| 849 |
}
|
| 850 |
setTimeout(loop, delay);
|