Selinore commited on
Commit
4ca3b83
·
verified ·
1 Parent(s): 77d2e53

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +92 -241
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
- BOT_USERNAME: BOT_USERNAME || '',
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: STORAGE_ACCESS_KEY_ID && STORAGE_SECRET_ACCESS_KEY
94
- ? {
95
- accessKeyId: STORAGE_ACCESS_KEY_ID,
96
- secretAccessKey: STORAGE_SECRET_ACCESS_KEY
97
- }
98
- : undefined
 
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', (c) => chunks.push(c));
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((ch) => {
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(`![图片](${item.url})`);
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.botUsername === 'string' && body.botUsername.trim()) {
424
- runtimeConfig.botUsername = body.botUsername.trim();
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
- const nextProfile = { ...runtimeConfig.uiProfile };
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
- res.status(403).json({ error: '频道由S3同步,禁止新增' });
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((m) => m.id === lastSeen);
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
- JSON.stringify({
759
- type: 'req',
760
- id: crypto.randomUUID(),
761
- method: 'connect',
762
- params: {
763
- minProtocol: 3,
764
- maxProtocol: 3,
765
- client: { id: 'openclaw-control-ui', version: 'dev', platform: 'Win32', mode: 'webchat' },
766
- device: {
767
- id: deviceId,
768
- publicKey: toB64Url(rawPublicKey),
769
- signature: toB64Url(signature),
770
- signedAt,
771
- nonce
772
- },
773
- auth: { token: OPENCLAW_TOKEN },
774
- role: 'operator',
775
- scopes: ['operator.admin', 'operator.approvals', 'operator.pairing']
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
- JSON.stringify({
818
- type: 'req',
819
- id: crypto.randomUUID(),
820
- method: isCommand ? 'chat.send' : 'agent',
821
- params: {
822
- sessionKey: `agent:main:${cid}`,
823
- message: finalMsg,
824
- idempotencyKey: crypto.randomUUID(),
825
- deliver: isCommand ? false : undefined
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((m) => (m.content || m.data?.content || '').trim()).filter(Boolean));
853
 
854
  const activeUsers = new Map();
855
- msgs.slice(0, 5).forEach((m) => {
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((r) => setTimeout(r, 200));
861
  const history = await fetchClawHistory(cid, u.id);
862
- const replies = history.filter((h) => h.role === 'assistant').slice(-2);
 
863
  for (const r of replies) {
864
  const txt =
865
  typeof r.content === 'string'
866
  ? r.content.trim()
867
  : Array.isArray(r.content)
868
- ? (r.content[0]?.text || '').trim()
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((m) => m.id === state.lastMsgId);
885
 
886
- if (state.lastMsgId === 'EMPTY_START') {
887
- newMsgs = sorted;
888
- } else if (startIdx !== -1) {
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((r) => setTimeout(r, 500));
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((r) => setTimeout(r, 200));
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(`![图片](${item.url})`);
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);