Selinore commited on
Commit
9cd6340
·
verified ·
1 Parent(s): d58796c

Update server.js

Browse files
Files changed (1) hide show
  1. server.js +325 -528
server.js CHANGED
@@ -546,10 +546,10 @@ const {
546
  } = require('@aws-sdk/client-s3');
547
 
548
  // ============================================================
549
- // 0) 配置
550
  // ============================================================
551
 
552
- const PORT = parseInt(process.env.PORT || '7860', 10);
553
  const {
554
  OPENWEBUI_BASE_URL,
555
  OPENWEBUI_API_KEY,
@@ -557,7 +557,6 @@ const {
557
  OPENCLAW_TOKEN,
558
  BOT_USERNAME = 'openman',
559
  POLL_INTERVAL = '5000',
560
-
561
  STORAGE_EXTERNAL_ENDPOINT,
562
  STORAGE_S3_FORCE_PATH_STYLE = 'true',
563
  STORAGE_PUBLIC_BUCKET,
@@ -568,19 +567,13 @@ const {
568
 
569
  const S3_CONFIG_KEY = 'openclaw/channels.json';
570
  const S3_UPLOAD_PREFIX = 'openclaw/uploads/';
571
- const CLEAN_BEFORE_MS = 3 * 24 * 60 * 60 * 1000; // 3天
572
 
573
- if (!OPENWEBUI_BASE_URL || !OPENWEBUI_API_KEY || !OPENCLAW_WS_URL || !OPENCLAW_TOKEN) {
574
- console.error('缺少必要环境变量: OPENWEBUI_BASE_URL / OPENWEBUI_API_KEY / OPENCLAW_WS_URL / OPENCLAW_TOKEN');
575
- process.exit(1);
576
- }
577
- if (!STORAGE_EXTERNAL_ENDPOINT || !STORAGE_PUBLIC_BUCKET || !STORAGE_ACCESS_KEY_ID || !STORAGE_SECRET_ACCESS_KEY) {
578
- console.error('缺少 S3 环境变量: STORAGE_EXTERNAL_ENDPOINT / STORAGE_PUBLIC_BUCKET / STORAGE_ACCESS_KEY_ID / STORAGE_SECRET_ACCESS_KEY');
579
- process.exit(1);
580
- }
581
 
582
  // ============================================================
583
- // 1) 日志
584
  // ============================================================
585
 
586
  const logBuffer = [];
@@ -591,37 +584,35 @@ function pushLog(type, msg) {
591
  else console.log(line);
592
 
593
  logBuffer.unshift(line);
594
- if (logBuffer.length > 100) logBuffer.pop();
595
  }
596
  const log = (m) => pushLog('info', m);
597
  const err = (m) => pushLog('err', m);
598
 
599
  // ============================================================
600
- // 2) 客户端
601
  // ============================================================
602
 
603
  const webuiApi = axios.create({
604
  baseURL: OPENWEBUI_BASE_URL,
605
  headers: {
606
- 'Content-Type': 'application/json',
607
- Authorization: `Bearer ${OPENWEBUI_API_KEY}`
608
  },
609
  timeout: 15000
610
  });
611
 
612
  const fileDownloader = axios.create({
613
  baseURL: OPENWEBUI_BASE_URL,
614
- headers: {
615
- Authorization: `Bearer ${OPENWEBUI_API_KEY}`
616
- },
617
  responseType: 'arraybuffer',
618
- timeout: 45000
619
  });
620
 
621
  const s3 = new S3Client({
622
  region: STORAGE_REGION,
623
  endpoint: STORAGE_EXTERNAL_ENDPOINT,
624
- forcePathStyle: String(STORAGE_S3_FORCE_PATH_STYLE).toLowerCase() === 'true',
625
  credentials: {
626
  accessKeyId: STORAGE_ACCESS_KEY_ID,
627
  secretAccessKey: STORAGE_SECRET_ACCESS_KEY
@@ -629,155 +620,51 @@ const s3 = new S3Client({
629
  });
630
 
631
  // ============================================================
632
- // 3) 状态
633
  // ============================================================
634
 
635
- const channelRuntime = new Map(); // id -> {id, name, lastMsgId, syncing, syncStartTime}
636
- const pendingRequests = new Map();
 
 
 
 
 
 
 
 
637
 
638
  let ws = null;
639
  let isClawReady = false;
640
  let isReconnecting = false;
641
  let pingInterval = null;
642
  let reconnectTimer = null;
 
643
 
644
  // ============================================================
645
- // 4) 工具函数
646
  // ============================================================
647
 
648
- function sleep(ms) {
649
- return new Promise((r) => setTimeout(r, ms));
650
- }
651
-
652
- function sanitizeFilename(name = 'file.bin') {
653
- return String(name).replace(/[^a-zA-Z0-9._-]/g, '_');
654
- }
655
-
656
- function toB64Url(buf) {
657
- return buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
658
- }
659
-
660
  async function streamToString(stream) {
661
  return await new Promise((resolve, reject) => {
662
  const chunks = [];
663
  stream.on('data', (c) => chunks.push(c));
664
  stream.on('error', reject);
665
- stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
666
  });
667
  }
668
 
669
- function buildS3PublicUrl(key) {
670
- // path style: endpoint/bucket/key
671
- const base = STORAGE_EXTERNAL_ENDPOINT.replace(/\/+$/, '');
672
- return `${base}/${STORAGE_PUBLIC_BUCKET}/${key}`;
673
- }
674
-
675
- function guessMimeByExt(filename = '') {
676
- const ext = filename.toLowerCase().split('.').pop();
677
- const map = {
678
- jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp', svg: 'image/svg+xml',
679
- mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', m4a: 'audio/mp4', aac: 'audio/aac', flac: 'audio/flac',
680
- mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mkv: 'video/x-matroska',
681
- txt: 'text/plain', md: 'text/markdown', pdf: 'application/pdf', json: 'application/json'
682
- };
683
- return map[ext] || 'application/octet-stream';
684
- }
685
-
686
- // ============================================================
687
- // 5) OpenWebUI 回传格式化(关键)
688
- // ============================================================
689
-
690
- function renderUrlForOpenWebUI(url) {
691
- const clean = String(url || '').trim();
692
- if (!clean) return '';
693
-
694
- const lower = clean.toLowerCase().split('?')[0];
695
-
696
- // 音频
697
- if (/\.(mp3|wav|ogg|m4a|aac|flac)$/.test(lower)) {
698
- const ext = lower.split('.').pop();
699
- const mimeMap = {
700
- mp3: 'audio/mpeg',
701
- wav: 'audio/wav',
702
- ogg: 'audio/ogg',
703
- m4a: 'audio/mp4',
704
- aac: 'audio/aac',
705
- flac: 'audio/flac'
706
- };
707
- const mime = mimeMap[ext] || 'audio/mpeg';
708
- return `<audio controls>
709
- <source src="${clean}" type="${mime}">
710
- 您的浏览器不支持音频播放。
711
- </audio>`;
712
- }
713
-
714
- // 图片
715
- if (/\.(png|jpg|jpeg|gif|webp|bmp|svg)$/.test(lower)) {
716
- return `![image](${clean})`;
717
- }
718
-
719
- // 视频
720
- if (/\.(mp4|webm|mov|mkv)$/.test(lower)) {
721
- return `<video controls style="max-width:100%;">
722
- <source src="${clean}">
723
- 您的浏览器不支持视频播放。
724
- </video>`;
725
- }
726
-
727
- // 其它
728
- return `[文件下载](${clean})`;
729
- }
730
-
731
- function normalizeAssistantContent(hMsg) {
732
- const c = hMsg?.content;
733
- if (!c) return '';
734
-
735
- if (typeof c === 'string') {
736
- return c.trim();
737
- }
738
-
739
- if (Array.isArray(c)) {
740
- const parts = [];
741
- for (const block of c) {
742
- if (!block) continue;
743
-
744
- if (typeof block.text === 'string' && block.text.trim()) {
745
- parts.push(block.text.trim());
746
- continue;
747
- }
748
- if (typeof block.value === 'string' && block.value.trim()) {
749
- parts.push(block.value.trim());
750
- continue;
751
- }
752
-
753
- // URL 字段兼容
754
- const url = block.url || block.href || block.link || block.src;
755
- if (url) {
756
- parts.push(renderUrlForOpenWebUI(url));
757
- continue;
758
- }
759
- }
760
- return parts.join('\n\n').trim();
761
- }
762
-
763
- return String(c).trim();
764
- }
765
-
766
- // ============================================================
767
- // 6) S3:配置/上传/清理
768
- // ============================================================
769
-
770
  async function loadChannelsFromS3() {
771
  try {
772
  log('☁️ [S3] 读取频道配置...');
773
- const res = await s3.send(new GetObjectCommand({
774
  Bucket: STORAGE_PUBLIC_BUCKET,
775
  Key: S3_CONFIG_KEY
776
  }));
777
- const text = await streamToString(res.Body);
778
- const json = JSON.parse(text);
 
779
 
780
- const list = Array.isArray(json?.channels) ? json.channels : [];
781
  for (const ch of list) {
782
  if (!channelRuntime.has(ch.id)) {
783
  channelRuntime.set(ch.id, {
@@ -791,22 +678,15 @@ async function loadChannelsFromS3() {
791
  }
792
  log(`✅ [S3] 恢复频道 ${list.length} 个`);
793
  } catch (e) {
794
- if (e.name === 'NoSuchKey') {
795
- log('ℹ️ [S3] channels.json 不存在,首次启动');
796
- } else {
797
- err(`[S3] 读取配置失败: ${e.message}`);
798
- }
799
  }
800
  }
801
 
802
  async function saveChannelsToS3() {
803
  try {
804
- const list = Array.from(channelRuntime.values()).map((x) => ({ id: x.id, name: x.name }));
805
- const body = JSON.stringify({
806
- version: '2.2.0',
807
- updatedAt: new Date().toISOString(),
808
- channels: list
809
- }, null, 2);
810
 
811
  await s3.send(new PutObjectCommand({
812
  Bucket: STORAGE_PUBLIC_BUCKET,
@@ -815,70 +695,175 @@ async function saveChannelsToS3() {
815
  ContentType: 'application/json'
816
  }));
817
  } catch (e) {
818
- err(`[S3] 保存配置失败: ${e.message}`);
819
  }
820
  }
821
 
822
- async function uploadToS3(filename, buffer, mimeType) {
823
  const key = `${S3_UPLOAD_PREFIX}${filename}`;
824
  try {
825
- log(`☁️ [Debug] S3上传开始: ${filename}, ${buffer.length} bytes, mime=${mimeType}`);
826
  await s3.send(new PutObjectCommand({
827
  Bucket: STORAGE_PUBLIC_BUCKET,
828
  Key: key,
829
  Body: buffer,
830
- ContentType: mimeType || 'application/octet-stream'
831
- // 不强行 ACL,避免部分 S3 报 AccessControlListNotSupported
832
  }));
833
- const url = buildS3PublicUrl(key);
 
 
 
834
  log(`✅ [Debug] S3上传成功: ${url}`);
835
  return url;
836
  } catch (e) {
837
- err(`S3上传失败 ${filename}: ${e.message}`);
838
  return null;
839
  }
840
  }
841
 
842
  async function cleanOldS3Files() {
843
  try {
844
- log('🧹 [S3] 清理任务开始');
845
- const threshold = Date.now() - CLEAN_BEFORE_MS;
846
- let token = undefined;
847
- let removed = 0;
848
 
849
  do {
850
- const res = await s3.send(new ListObjectsV2Command({
851
  Bucket: STORAGE_PUBLIC_BUCKET,
852
  Prefix: S3_UPLOAD_PREFIX,
853
  ContinuationToken: token
854
  }));
855
- const list = res.Contents || [];
856
 
857
- for (const obj of list) {
858
- if (obj.LastModified && obj.LastModified.getTime() < threshold) {
859
  await s3.send(new DeleteObjectCommand({
860
  Bucket: STORAGE_PUBLIC_BUCKET,
861
- Key: obj.Key
862
  }));
863
- removed += 1;
864
  }
865
  }
866
- token = res.NextContinuationToken;
867
  } while (token);
868
 
869
- log(`🧹 [S3] 清理完成,删除 ${removed} 个文件`);
870
  } catch (e) {
871
  err(`[S3] 清理失败: ${e.message}`);
872
  }
873
  }
874
 
875
  // ============================================================
876
- // 7) OpenClaw WS
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
877
  // ============================================================
878
 
879
  const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
880
  const rawPublicKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(12);
881
  const deviceId = crypto.createHash('sha256').update(rawPublicKey).digest('hex');
 
882
 
883
  function cleanupConnection() {
884
  if (pingInterval) clearInterval(pingInterval);
@@ -889,42 +874,38 @@ function cleanupConnection() {
889
  try { ws.terminate(); } catch {}
890
  ws = null;
891
  }
892
-
893
  isClawReady = false;
894
  pendingRequests.clear();
895
 
896
- for (const st of channelRuntime.values()) {
897
- st.syncing = false;
898
- }
899
  }
900
 
901
- function handleDisconnect(retryCount) {
902
  cleanupConnection();
903
  isReconnecting = false;
904
- const delay = Math.min(5000 * Math.pow(1.5, retryCount), 60000);
905
  if (reconnectTimer) clearTimeout(reconnectTimer);
906
- reconnectTimer = setTimeout(() => connectOpenClaw(retryCount + 1), delay);
907
  }
908
 
909
- function connectOpenClaw(retryCount = 0) {
910
  if (isReconnecting || ws) return;
911
  isReconnecting = true;
912
- log(`🔌 连接 OpenClaw (${retryCount + 1})...`);
913
 
914
  try {
915
  ws = new WebSocket(OPENCLAW_WS_URL.replace(/\/+$/, '') + '/ws');
916
 
917
  ws.on('open', () => {
918
- log('✅ WS 握手成功');
919
  isReconnecting = false;
920
-
921
  ws.isAlive = true;
922
- ws.on('pong', () => { ws.isAlive = true; });
923
 
 
924
  pingInterval = setInterval(() => {
925
  if (!ws) return;
926
  if (ws.isAlive === false) {
927
- err('WS 心跳超时,终止连接');
928
  return ws.terminate();
929
  }
930
  ws.isAlive = false;
@@ -932,28 +913,24 @@ function connectOpenClaw(retryCount = 0) {
932
  }, 10000);
933
  });
934
 
935
- ws.on('message', handleClawMessage);
936
  ws.on('close', (code) => {
937
- log(`🔌 WS断开 (${code})`);
938
- handleDisconnect(retryCount);
939
  });
940
  ws.on('error', (e) => err(`WS错误: ${e.message}`));
941
  } catch (e) {
942
  err(`WS初始化失败: ${e.message}`);
943
- handleDisconnect(retryCount);
944
  }
945
  }
946
 
947
- function handleClawMessage(data) {
948
  let msg;
949
- try {
950
- msg = JSON.parse(data.toString());
951
- } catch {
952
- return;
953
- }
954
 
955
  if (msg.event === 'connect.challenge') {
956
- const { nonce } = msg.payload || {};
957
  const signedAt = Date.now();
958
  const payload = [
959
  'v2',
@@ -964,7 +941,7 @@ function handleClawMessage(data) {
964
  'operator.admin,operator.approvals,operator.pairing',
965
  String(signedAt),
966
  OPENCLAW_TOKEN || '',
967
- nonce || ''
968
  ].join('|');
969
 
970
  const signature = crypto.sign(null, Buffer.from(payload), privateKey);
@@ -976,12 +953,7 @@ function handleClawMessage(data) {
976
  params: {
977
  minProtocol: 3,
978
  maxProtocol: 3,
979
- client: {
980
- id: 'openclaw-control-ui',
981
- version: 'dev',
982
- platform: 'linux',
983
- mode: 'webchat'
984
- },
985
  device: {
986
  id: deviceId,
987
  publicKey: toB64Url(rawPublicKey),
@@ -1002,422 +974,247 @@ function handleClawMessage(data) {
1002
  }
1003
 
1004
  if (msg.type === 'res' && msg.id && pendingRequests.has(msg.id)) {
1005
- const cb = pendingRequests.get(msg.id);
1006
  pendingRequests.delete(msg.id);
1007
- try { cb(msg.payload); } catch {}
1008
  }
1009
  }
1010
 
1011
- function fetchClawHistory(cid, uid) {
1012
- return new Promise((resolve) => {
1013
- if (!isClawReady || !ws) return resolve([]);
1014
- const reqId = crypto.randomUUID();
1015
-
1016
- const timer = setTimeout(() => {
1017
- pendingRequests.delete(reqId);
1018
- resolve([]);
1019
- }, 5000);
1020
-
1021
- pendingRequests.set(reqId, (payload) => {
1022
- clearTimeout(timer);
1023
- resolve(payload?.messages || []);
1024
- });
1025
-
1026
- try {
1027
- ws.send(JSON.stringify({
1028
- type: 'req',
1029
- id: reqId,
1030
- method: 'chat.history',
1031
- params: {
1032
- sessionKey: `agent:main:${cid}`,
1033
- limit: 5
1034
- }
1035
- }));
1036
- } catch {
1037
- clearTimeout(timer);
1038
- pendingRequests.delete(reqId);
1039
- resolve([]);
1040
- }
1041
- });
1042
- }
1043
-
1044
- function triggerAgent(cid, uid, senderName, text, isCommand = false) {
1045
  if (!isClawReady || !ws) return;
1046
- const finalMsg = isCommand ? text : `${senderName} 说: ${text}`;
1047
-
1048
- ws.send(JSON.stringify({
1049
  type: 'req',
1050
  id: crypto.randomUUID(),
1051
- method: isCommand ? 'chat.send' : 'agent',
1052
  params: {
1053
- sessionKey: `agent:main:${cid}`,
1054
- message: finalMsg,
1055
  idempotencyKey: crypto.randomUUID(),
1056
  deliver: isCommand ? false : undefined
1057
  }
1058
- }));
1059
-
1060
- log(`🚀 [发送到Claw] ${senderName}: ${String(text).slice(0, 60)}`);
1061
  }
1062
 
1063
  // ============================================================
1064
- // 8) 频道初始化
1065
  // ============================================================
1066
 
1067
  async function initChannelBaseline(cid) {
1068
- const state = channelRuntime.get(cid);
1069
- if (!state) return;
 
1070
  try {
1071
- const { data } = await webuiApi.get(`/api/v1/channels/${cid}/messages?limit=1`);
1072
- state.lastMsgId = (Array.isArray(data) && data.length > 0) ? data[0].id : 'EMPTY_START';
1073
- log(`✅ [${state.name}] 基准线: ${state.lastMsgId}`);
 
 
 
 
1074
  } catch (e) {
1075
- err(`[${state.name}] 初始化失败,5秒后重试: ${e.message}`);
1076
  setTimeout(() => initChannelBaseline(cid), 5000);
1077
  }
1078
  }
1079
 
1080
- // ============================================================
1081
- // 9) 核心同步
1082
- // ============================================================
 
 
1083
 
1084
- async function fetchMessageDataIfNeeded(cid, msg) {
1085
- // 新版: msg.data === true,需要拉详情接口
1086
- if (msg?.data === true) {
1087
- log(`📡 [Debug] 懒加载消息,拉取详情: messageId=${msg.id}`);
1088
  try {
1089
  const { data } = await webuiApi.get(`/api/v1/channels/${cid}/messages/${msg.id}/data`);
1090
- return data || {};
 
 
 
 
 
1091
  } catch (e) {
1092
- err(`消息详情获取失败 ${msg.id}: ${e.message}`);
1093
- return {};
1094
  }
 
 
1095
  }
1096
 
1097
- // 旧版: msg.data 是对象
1098
- if (msg?.data && typeof msg.data === 'object') return msg.data;
1099
-
1100
- return {};
1101
  }
1102
 
1103
- async function processAttachmentsToMarkdown(cid, msg) {
1104
- const messageData = await fetchMessageDataIfNeeded(cid, msg);
1105
- const files = Array.isArray(messageData?.files) ? messageData.files : [];
1106
-
1107
- if (!files.length) return [];
1108
 
1109
- log(`🔎 [Debug] message=${msg.id} 附件数=${files.length}`);
1110
 
1111
- const atts = [];
1112
- for (const fItem of files) {
1113
  try {
1114
- const f = fItem?.file || fItem || {};
1115
  let buffer = null;
1116
- let filename = null;
1117
- let mimeType = null;
1118
-
1119
- // 1) 兼容粘贴 data URL
1120
- if (typeof fItem?.url === 'string' && fItem.url.startsWith('data:')) {
1121
- log(`📸 [Debug] 处理 Base64 附件`);
1122
- const m = fItem.url.match(/^data:([a-zA-Z0-9/+\-.]+);base64,(.+)$/);
1123
- if (m) {
1124
- mimeType = m[1];
1125
- const ext = (mimeType.split('/')[1] || 'bin').replace('jpeg', 'jpg');
1126
- filename = `img_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
1127
- buffer = Buffer.from(m[2], 'base64');
1128
  }
 
 
1129
  }
1130
 
1131
- // 2) 通过 file id 下载(新版主流程)
1132
- if (!buffer && f?.id) {
1133
- const originalName = f.filename || fItem.name || 'file.bin';
1134
- log(`⬇️ [Debug] 下载文件: id=${f.id}, name=${originalName}`);
1135
- const { data } = await fileDownloader.get(`/api/v1/files/${f.id}/content`);
 
 
 
 
1136
  buffer = data;
1137
- filename = `${Date.now()}_${sanitizeFilename(originalName)}`;
1138
- mimeType = fItem.content_type || f.meta?.content_type || guessMimeByExt(originalName);
1139
- }
1140
-
1141
- if (!buffer || !filename) {
1142
- log(`⚠️ [Debug] 附件跳过(无可用数据): ${JSON.stringify({ id: f?.id, name: fItem?.name || f?.filename || '' })}`);
1143
- continue;
1144
  }
1145
 
1146
- const url = await uploadToS3(filename, buffer, mimeType);
 
1147
  if (!url) continue;
1148
 
1149
- const isImage = String(mimeType || '').startsWith('image/');
1150
- atts.push(`[${isImage ? '图片' : '文件'}: ${filename}](${url}) (附件)`);
1151
- log(`✅ [Debug] 附件转链成功: ${filename}`);
1152
  } catch (e) {
1153
- err(`附件处理失败: ${e.message}`);
1154
  }
1155
  }
1156
 
1157
  return atts;
1158
  }
1159
 
1160
- async function syncChannel(cid) {
1161
- const state = channelRuntime.get(cid);
1162
- if (!state || !state.lastMsgId || !isClawReady) return;
1163
 
1164
- if (state.syncing) {
1165
- if (Date.now() - (state.syncStartTime || 0) > 60000) {
1166
- log(`⚠️ [${state.name}] 同步超时,强制释放`);
1167
- state.syncing = false;
 
 
 
 
 
1168
  } else {
1169
  return;
1170
  }
1171
  }
1172
 
1173
- state.syncing = true;
1174
- state.syncStartTime = Date.now();
1175
 
1176
  try {
1177
- // 拉最近消息
1178
  const { data: msgs } = await webuiApi.get(`/api/v1/channels/${cid}/messages?skip=0&limit=10`);
1179
- if (!Array.isArray(msgs) || !msgs.length) throw new Error('No messages');
1180
-
1181
- // A) 回信补发:Claw -> WebUI
1182
- const webuiFingerprints = new Set(
1183
- msgs
1184
- .map((m) => (m.content || m.data?.content || '').trim())
1185
- .filter(Boolean)
1186
- );
1187
-
1188
- const activeUsers = new Map();
1189
- msgs.slice(0, 5).forEach((m) => {
1190
- if (m.user_id) activeUsers.set(m.user_id, { id: m.user_id, name: m.user?.name || 'User' });
1191
- });
1192
 
1193
- for (const u of activeUsers.values()) {
1194
- await sleep(120);
1195
- const history = await fetchClawHistory(cid, u.id);
1196
- const replies = history.filter((x) => x.role === 'assistant').slice(-3);
1197
-
1198
- for (const hMsg of replies) {
1199
- const txt = normalizeAssistantContent(hMsg);
1200
- if (txt && !webuiFingerprints.has(txt)) {
1201
- log(`🧪 [Debug] 回传内容预览: ${txt.slice(0, 180).replace(/\n/g, ' ')}`);
1202
- await webuiApi.post(`/api/v1/channels/${cid}/messages/post`, {
1203
- content: txt,
1204
- data: { files: [] },
1205
- reply_to_id: null
1206
- });
1207
- webuiFingerprints.add(txt);
1208
- log(`📥 [回传OpenWebUI] -> ${u.name}`);
1209
- }
 
1210
  }
1211
  }
1212
 
1213
- // B) 新消息转发:WebUI -> Claw
1214
- const sorted = msgs.reverse(); // 旧->新
1215
- const idx = sorted.findIndex((m) => m.id === state.lastMsgId);
1216
- const newMsgs = (idx !== -1) ? sorted.slice(idx + 1) : (state.lastMsgId !== 'EMPTY_START' ? sorted : []);
 
1217
 
1218
- if (newMsgs.length > 0) {
1219
- state.lastMsgId = sorted[sorted.length - 1].id;
 
 
1220
 
1221
- for (const m of newMsgs) {
1222
- const sender = m.user?.name || 'User';
1223
- if (sender.toLowerCase() === BOT_USERNAME.toLowerCase()) continue;
 
1224
 
1225
- let txt = String(m.content || m.data?.content || '')
1226
- .replace(/<@U:[^>]+>/g, '')
1227
- .replace(new RegExp(`@${BOT_USERNAME}`, 'gi'), '')
1228
- .trim();
1229
 
1230
- // 处理附件(新接口)
1231
- const atts = await processAttachmentsToMarkdown(cid, m);
1232
- if (atts.length) txt += (txt ? '\n\n' : '') + atts.join('\n');
1233
 
1234
- if (!txt && atts.length > 0) txt = '发送了附件。';
1235
- if (!txt) continue;
1236
 
1237
- const isCommand = txt.trim().startsWith('/');
1238
- await sleep(180);
1239
- triggerAgent(cid, m.user_id, sender, txt, isCommand);
1240
- }
1241
  }
 
 
 
 
1242
  } catch (e) {
1243
- if (e.message !== 'No messages') err(`[${state.name}] 同步错误: ${e.message}`);
1244
  } finally {
1245
- state.syncing = false;
1246
  }
1247
  }
1248
 
1249
- async function pollingLoop() {
1250
- const interval = Math.max(parseInt(POLL_INTERVAL, 10) || 5000, 2000);
1251
-
1252
  if (isClawReady) {
1253
- const ids = Array.from(channelRuntime.keys());
1254
- for (const cid of ids) {
1255
  await syncChannel(cid);
1256
- await sleep(120);
1257
  }
1258
  }
1259
-
1260
- setTimeout(pollingLoop, interval);
1261
- }
1262
-
1263
- // ============================================================
1264
- // 10) Web 控制台
1265
- // ============================================================
1266
-
1267
- const app = express();
1268
- app.use(express.json());
1269
-
1270
- const HTML_PAGE = `
1271
- <!doctype html>
1272
- <html lang="zh">
1273
- <head>
1274
- <meta charset="UTF-8" />
1275
- <meta name="viewport" content="width=device-width,initial-scale=1" />
1276
- <title>Bridge Console</title>
1277
- <style>
1278
- body{font-family:system-ui;background:#f4f6f8;margin:0;padding:20px}
1279
- .wrap{max-width:980px;margin:0 auto;display:grid;gap:16px}
1280
- .card{background:#fff;border-radius:12px;padding:16px;box-shadow:0 1px 4px rgba(0,0,0,.08)}
1281
- .row{display:flex;gap:8px;align-items:center;flex-wrap:wrap}
1282
- input{padding:8px;border:1px solid #ddd;border-radius:8px}
1283
- button{padding:8px 12px;border:0;border-radius:8px;background:#2563eb;color:#fff;cursor:pointer}
1284
- button.del{background:#dc2626}
1285
- .badge{padding:3px 8px;border-radius:999px;font-size:12px}
1286
- .ok{background:#dcfce7;color:#166534}.bad{background:#fee2e2;color:#991b1b}
1287
- table{width:100%;border-collapse:collapse}
1288
- th,td{padding:8px;border-bottom:1px solid #eee;text-align:left}
1289
- #logs{height:340px;overflow:auto;background:#111827;color:#86efac;padding:10px;border-radius:8px;font-family:monospace;font-size:12px;white-space:pre-wrap}
1290
- </style>
1291
- </head>
1292
- <body>
1293
- <div class="wrap">
1294
- <div class="card row" style="justify-content:space-between">
1295
- <h2 style="margin:0">🌉 OpenWebUI ↔ OpenClaw Bridge</h2>
1296
- <div>WS: <span id="ws" class="badge bad">Disconnected</span></div>
1297
- </div>
1298
-
1299
- <div class="card">
1300
- <h3 style="margin-top:0">频道管理(S3持久化)</h3>
1301
- <div class="row">
1302
- <input id="cid" placeholder="Channel UUID" style="flex:1;min-width:260px" />
1303
- <input id="cname" placeholder="Name(可选)" style="flex:1;min-width:180px" />
1304
- <button onclick="addCh()">添加</button>
1305
- </div>
1306
- <table id="tbl">
1307
- <thead><tr><th>Name</th><th>ID</th><th>Status</th><th>Action</th></tr></thead>
1308
- <tbody></tbody>
1309
- </table>
1310
- </div>
1311
-
1312
- <div class="card">
1313
- <h3 style="margin-top:0">运行日志(最近100条)</h3>
1314
- <div id="logs"></div>
1315
- </div>
1316
- </div>
1317
-
1318
- <script>
1319
- async function refresh(){
1320
- try{
1321
- const h = await (await fetch('/health')).json();
1322
- const ws = document.getElementById('ws');
1323
- ws.textContent = h.wsConnected ? 'Connected' : 'Disconnected';
1324
- ws.className = 'badge ' + (h.wsConnected ? 'ok' : 'bad');
1325
-
1326
- const ch = await (await fetch('/api/channels')).json();
1327
- const tb = document.querySelector('#tbl tbody');
1328
- tb.innerHTML = ch.map(c => \`
1329
- <tr>
1330
- <td>\${c.name}</td>
1331
- <td style="font-family:monospace">\${c.id}</td>
1332
- <td>\${c.syncing ? '🔄 syncing' : '💤 idle'}</td>
1333
- <td><button class="del" onclick="delCh('\${c.id}')">删除</button></td>
1334
- </tr>\`
1335
- ).join('') || '<tr><td colspan="4" style="color:#999">暂无频道</td></tr>';
1336
-
1337
- const logs = await (await fetch('/api/logs')).json();
1338
- document.getElementById('logs').innerHTML = logs.map(x => x.replace(/</g,'&lt;')).join('\\n');
1339
- }catch(e){}
1340
- }
1341
- async function addCh(){
1342
- const id = document.getElementById('cid').value.trim();
1343
- const name = document.getElementById('cname').value.trim();
1344
- if(!id) return alert('请输入频道ID');
1345
- await fetch('/api/channels',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id,name})});
1346
- document.getElementById('cid').value = '';
1347
- refresh();
1348
  }
1349
- async function delCh(id){
1350
- if(!confirm('确认删除?')) return;
1351
- await fetch('/api/channels/'+encodeURIComponent(id),{method:'DELETE'});
1352
- refresh();
1353
- }
1354
- refresh();
1355
- setInterval(refresh,3000);
1356
- </script>
1357
- </body>
1358
- </html>
1359
- `;
1360
-
1361
- app.get('/', (req, res) => res.send(HTML_PAGE));
1362
- app.get('/api/logs', (req, res) => res.json(logBuffer));
1363
- app.get('/api/channels', (req, res) => res.json(Array.from(channelRuntime.values())));
1364
- app.get('/health', (req, res) => res.json({ status: 'ok', wsConnected: isClawReady }));
1365
-
1366
- app.post('/api/channels', async (req, res) => {
1367
- const { id, name } = req.body || {};
1368
- if (!id) return res.status(400).json({ success: false, error: 'id required' });
1369
-
1370
- if (!channelRuntime.has(id)) {
1371
- channelRuntime.set(id, {
1372
- id,
1373
- name: name || id,
1374
- lastMsgId: null,
1375
- syncing: false,
1376
- syncStartTime: 0
1377
- });
1378
- await saveChannelsToS3();
1379
- initChannelBaseline(id);
1380
- log(`➕ 新增频道: ${id}`);
1381
- }
1382
- res.json({ success: true });
1383
- });
1384
-
1385
- app.delete('/api/channels/:id', async (req, res) => {
1386
- const id = req.params.id;
1387
- if (channelRuntime.delete(id)) {
1388
- await saveChannelsToS3();
1389
- log(`➖ 删除频道: ${id}`);
1390
- }
1391
- res.json({ success: true });
1392
- });
1393
 
1394
  // ============================================================
1395
- // 11) 启动
1396
  // ============================================================
1397
 
1398
  async function init() {
1399
- log(`🚀 服务启动端口: ${PORT}`);
1400
- log(`🧩 BOT_USERNAME: ${BOT_USERNAME}`);
1401
- log(`📦 S3 Bucket: ${STORAGE_PUBLIC_BUCKET}`);
1402
-
1403
  await loadChannelsFromS3();
1404
 
1405
- // 启动后为所有频道建立基线
1406
  for (const cid of channelRuntime.keys()) {
1407
- initChannelBaseline(cid);
1408
  }
1409
 
1410
  connectOpenClaw();
1411
-
1412
- // 每小时清理一次
1413
  setInterval(cleanOldS3Files, 60 * 60 * 1000);
1414
  cleanOldS3Files();
1415
-
1416
- pollingLoop();
1417
  }
1418
 
1419
  app.listen(PORT, () => {
1420
- init().catch((e) => err(`初始化失败: ${e.message}`));
1421
- });
1422
-
1423
-
 
546
  } = require('@aws-sdk/client-s3');
547
 
548
  // ============================================================
549
+ // 0. 配置
550
  // ============================================================
551
 
552
+ const PORT = Number(process.env.PORT || 7860);
553
  const {
554
  OPENWEBUI_BASE_URL,
555
  OPENWEBUI_API_KEY,
 
557
  OPENCLAW_TOKEN,
558
  BOT_USERNAME = 'openman',
559
  POLL_INTERVAL = '5000',
 
560
  STORAGE_EXTERNAL_ENDPOINT,
561
  STORAGE_S3_FORCE_PATH_STYLE = 'true',
562
  STORAGE_PUBLIC_BUCKET,
 
567
 
568
  const S3_CONFIG_KEY = 'openclaw/channels.json';
569
  const S3_UPLOAD_PREFIX = 'openclaw/uploads/';
570
+ const MAX_LOG_LINES = 100;
571
 
572
+ // 强制:不补历史(你要求的)
573
+ const STRICT_NO_HISTORY_REPLAY = true;
 
 
 
 
 
 
574
 
575
  // ============================================================
576
+ // 1. 日志
577
  // ============================================================
578
 
579
  const logBuffer = [];
 
584
  else console.log(line);
585
 
586
  logBuffer.unshift(line);
587
+ if (logBuffer.length > MAX_LOG_LINES) logBuffer.pop();
588
  }
589
  const log = (m) => pushLog('info', m);
590
  const err = (m) => pushLog('err', m);
591
 
592
  // ============================================================
593
+ // 2. 客户端
594
  // ============================================================
595
 
596
  const webuiApi = axios.create({
597
  baseURL: OPENWEBUI_BASE_URL,
598
  headers: {
599
+ Authorization: `Bearer ${OPENWEBUI_API_KEY}`,
600
+ 'Content-Type': 'application/json'
601
  },
602
  timeout: 15000
603
  });
604
 
605
  const fileDownloader = axios.create({
606
  baseURL: OPENWEBUI_BASE_URL,
607
+ headers: { Authorization: `Bearer ${OPENWEBUI_API_KEY}` },
 
 
608
  responseType: 'arraybuffer',
609
+ timeout: 30000
610
  });
611
 
612
  const s3 = new S3Client({
613
  region: STORAGE_REGION,
614
  endpoint: STORAGE_EXTERNAL_ENDPOINT,
615
+ forcePathStyle: STORAGE_S3_FORCE_PATH_STYLE === 'true',
616
  credentials: {
617
  accessKeyId: STORAGE_ACCESS_KEY_ID,
618
  secretAccessKey: STORAGE_SECRET_ACCESS_KEY
 
620
  });
621
 
622
  // ============================================================
623
+ // 3. 运行时状态
624
  // ============================================================
625
 
626
+ /**
627
+ * state:
628
+ * {
629
+ * id, name,
630
+ * lastMsgId,
631
+ * syncing,
632
+ * syncStartTime
633
+ * }
634
+ */
635
+ const channelRuntime = new Map();
636
 
637
  let ws = null;
638
  let isClawReady = false;
639
  let isReconnecting = false;
640
  let pingInterval = null;
641
  let reconnectTimer = null;
642
+ const pendingRequests = new Map();
643
 
644
  // ============================================================
645
+ // 4. S3 工具
646
  // ============================================================
647
 
 
 
 
 
 
 
 
 
 
 
 
 
648
  async function streamToString(stream) {
649
  return await new Promise((resolve, reject) => {
650
  const chunks = [];
651
  stream.on('data', (c) => chunks.push(c));
652
  stream.on('error', reject);
653
+ stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf-8')));
654
  });
655
  }
656
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
657
  async function loadChannelsFromS3() {
658
  try {
659
  log('☁️ [S3] 读取频道配置...');
660
+ const obj = await s3.send(new GetObjectCommand({
661
  Bucket: STORAGE_PUBLIC_BUCKET,
662
  Key: S3_CONFIG_KEY
663
  }));
664
+ const content = await streamToString(obj.Body);
665
+ const data = JSON.parse(content);
666
+ const list = Array.isArray(data.channels) ? data.channels : [];
667
 
 
668
  for (const ch of list) {
669
  if (!channelRuntime.has(ch.id)) {
670
  channelRuntime.set(ch.id, {
 
678
  }
679
  log(`✅ [S3] 恢复频道 ${list.length} 个`);
680
  } catch (e) {
681
+ if (e.name === 'NoSuchKey') log('ℹ️ [S3] 首次运行,无配置文件');
682
+ else err(`[S3] 读取配置失败: ${e.message}`);
 
 
 
683
  }
684
  }
685
 
686
  async function saveChannelsToS3() {
687
  try {
688
+ const channels = Array.from(channelRuntime.values()).map(c => ({ id: c.id, name: c.name }));
689
+ const body = JSON.stringify({ version: '1.0', channels, updatedAt: new Date().toISOString() }, null, 2);
 
 
 
 
690
 
691
  await s3.send(new PutObjectCommand({
692
  Bucket: STORAGE_PUBLIC_BUCKET,
 
695
  ContentType: 'application/json'
696
  }));
697
  } catch (e) {
698
+ err(`[S3] 保存频道失败: ${e.message}`);
699
  }
700
  }
701
 
702
+ async function uploadToS3(filename, buffer, contentType = 'application/octet-stream') {
703
  const key = `${S3_UPLOAD_PREFIX}${filename}`;
704
  try {
705
+ log(`☁️ [Debug] 上传S3: ${filename}, ${buffer.length} bytes`);
706
  await s3.send(new PutObjectCommand({
707
  Bucket: STORAGE_PUBLIC_BUCKET,
708
  Key: key,
709
  Body: buffer,
710
+ ContentType: contentType,
711
+ ACL: 'public-read'
712
  }));
713
+
714
+ let endpoint = STORAGE_EXTERNAL_ENDPOINT || '';
715
+ endpoint = endpoint.replace(/\/+$/, '');
716
+ const url = `${endpoint}/${STORAGE_PUBLIC_BUCKET}/${key}`;
717
  log(`✅ [Debug] S3上传成功: ${url}`);
718
  return url;
719
  } catch (e) {
720
+ err(`[S3] 上传失败 ${filename}: ${e.message}`);
721
  return null;
722
  }
723
  }
724
 
725
  async function cleanOldS3Files() {
726
  try {
727
+ const threshold = Date.now() - 3 * 24 * 3600 * 1000;
728
+ let token;
729
+ let deleted = 0;
 
730
 
731
  do {
732
+ const resp = await s3.send(new ListObjectsV2Command({
733
  Bucket: STORAGE_PUBLIC_BUCKET,
734
  Prefix: S3_UPLOAD_PREFIX,
735
  ContinuationToken: token
736
  }));
 
737
 
738
+ for (const o of (resp.Contents || [])) {
739
+ if (o.LastModified && o.LastModified.getTime() < threshold) {
740
  await s3.send(new DeleteObjectCommand({
741
  Bucket: STORAGE_PUBLIC_BUCKET,
742
+ Key: o.Key
743
  }));
744
+ deleted++;
745
  }
746
  }
747
+ token = resp.NextContinuationToken;
748
  } while (token);
749
 
750
+ if (deleted > 0) log(`🧹 [S3] 清理过期文件 ${deleted} 个`);
751
  } catch (e) {
752
  err(`[S3] 清理失败: ${e.message}`);
753
  }
754
  }
755
 
756
  // ============================================================
757
+ // 5. Web 管理页
758
+ // ============================================================
759
+
760
+ const app = express();
761
+ app.use(express.json());
762
+
763
+ app.get('/', (req, res) => {
764
+ res.send(`<!doctype html>
765
+ <html lang="zh"><head><meta charset="UTF-8"><title>Bridge Console</title>
766
+ <style>
767
+ body{font-family:system-ui;max-width:920px;margin:20px auto;background:#f3f4f6}
768
+ .card{background:#fff;padding:16px;border-radius:10px;margin-bottom:12px}
769
+ input{padding:8px;border:1px solid #ddd;border-radius:6px}
770
+ button{padding:8px 12px;border:0;border-radius:6px;background:#2563eb;color:#fff;cursor:pointer}
771
+ .small{font-size:12px;color:#666}
772
+ #logs{background:#0f172a;color:#10b981;padding:10px;height:360px;overflow:auto;border-radius:8px;font-family:monospace;font-size:12px}
773
+ td,th{padding:8px;border-bottom:1px solid #eee;text-align:left}
774
+ </style></head><body>
775
+ <div class="card">
776
+ <h2 style="margin:0 0 8px 0;">Bridge Console</h2>
777
+ <div>OpenClaw: <b id="st">...</b></div>
778
+ </div>
779
+
780
+ <div class="card">
781
+ <div style="display:flex;gap:8px;margin-bottom:8px">
782
+ <input id="cid" style="flex:1" placeholder="Channel UUID">
783
+ <input id="cname" style="flex:1" placeholder="Name">
784
+ <button onclick="add()">Add</button>
785
+ </div>
786
+ <table style="width:100%;border-collapse:collapse">
787
+ <thead><tr><th>Name</th><th>ID</th><th>Status</th><th>Action</th></tr></thead>
788
+ <tbody id="tb"></tbody>
789
+ </table>
790
+ <div class="small">严格模式:不补历史(删除会重置基准,不重发旧消息)</div>
791
+ </div>
792
+
793
+ <div class="card">
794
+ <h3 style="margin-top:0;">最近日志(100行)</h3>
795
+ <div id="logs"></div>
796
+ </div>
797
+
798
+ <script>
799
+ async function refresh(){
800
+ try{
801
+ const h = await (await fetch('/health')).json();
802
+ document.getElementById('st').innerText = h.wsConnected ? 'Connected' : 'Disconnected';
803
+
804
+ const cs = await (await fetch('/api/channels')).json();
805
+ document.getElementById('tb').innerHTML = cs.map(c=>\`
806
+ <tr><td>\${c.name}</td><td>\${c.id}</td><td>\${c.syncing?'🔄':'💤'}</td>
807
+ <td><button style="background:#dc2626" onclick="delCh('\${c.id}')">Delete</button></td></tr>\`).join('');
808
+
809
+ const logs = await (await fetch('/api/logs')).json();
810
+ document.getElementById('logs').innerHTML = logs.map(x=>'<div>'+x.replace(/</g,'&lt;')+'</div>').join('');
811
+ }catch(e){}
812
+ }
813
+ async function add(){
814
+ await fetch('/api/channels',{method:'POST',headers:{'content-type':'application/json'},
815
+ body:JSON.stringify({id:cid.value.trim(),name:cname.value.trim()})});
816
+ cid.value=''; cname.value=''; refresh();
817
+ }
818
+ async function delCh(id){
819
+ if(!confirm('Delete '+id+' ?')) return;
820
+ await fetch('/api/channels/'+encodeURIComponent(id),{method:'DELETE'});
821
+ refresh();
822
+ }
823
+ setInterval(refresh,3000); refresh();
824
+ </script></body></html>`);
825
+ });
826
+
827
+ app.get('/api/logs', (req, res) => res.json(logBuffer));
828
+ app.get('/api/channels', (req, res) => res.json(Array.from(channelRuntime.values())));
829
+ app.get('/health', (req, res) => res.json({ ok: true, wsConnected: isClawReady }));
830
+
831
+ app.post('/api/channels', async (req, res) => {
832
+ const { id, name } = req.body || {};
833
+ if (!id) return res.status(400).json({ ok: false, error: 'id required' });
834
+
835
+ if (!channelRuntime.has(id)) {
836
+ channelRuntime.set(id, {
837
+ id,
838
+ name: name || id,
839
+ lastMsgId: null,
840
+ syncing: false,
841
+ syncStartTime: 0
842
+ });
843
+ await saveChannelsToS3();
844
+ await initChannelBaseline(id);
845
+ log(`➕ 频道已添加: ${id}`);
846
+ }
847
+ res.json({ ok: true });
848
+ });
849
+
850
+ app.delete('/api/channels/:id', async (req, res) => {
851
+ const id = req.params.id;
852
+ if (channelRuntime.delete(id)) {
853
+ await saveChannelsToS3();
854
+ log(`➖ 频道已删除: ${id}`);
855
+ }
856
+ res.json({ ok: true });
857
+ });
858
+
859
+ // ============================================================
860
+ // 6. OpenClaw 连接
861
  // ============================================================
862
 
863
  const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
864
  const rawPublicKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(12);
865
  const deviceId = crypto.createHash('sha256').update(rawPublicKey).digest('hex');
866
+ const toB64Url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
867
 
868
  function cleanupConnection() {
869
  if (pingInterval) clearInterval(pingInterval);
 
874
  try { ws.terminate(); } catch {}
875
  ws = null;
876
  }
 
877
  isClawReady = false;
878
  pendingRequests.clear();
879
 
880
+ for (const st of channelRuntime.values()) st.syncing = false;
 
 
881
  }
882
 
883
+ function handleDisconnect(retry = 0) {
884
  cleanupConnection();
885
  isReconnecting = false;
886
+ const delay = Math.min(5000 * Math.pow(1.5, retry), 60000);
887
  if (reconnectTimer) clearTimeout(reconnectTimer);
888
+ reconnectTimer = setTimeout(() => connectOpenClaw(retry + 1), delay);
889
  }
890
 
891
+ function connectOpenClaw(retry = 0) {
892
  if (isReconnecting || ws) return;
893
  isReconnecting = true;
894
+ log(`🔌 连接 OpenClaw (${retry + 1})...`);
895
 
896
  try {
897
  ws = new WebSocket(OPENCLAW_WS_URL.replace(/\/+$/, '') + '/ws');
898
 
899
  ws.on('open', () => {
 
900
  isReconnecting = false;
 
901
  ws.isAlive = true;
902
+ log('✅ WS 握手成功');
903
 
904
+ ws.on('pong', () => { ws.isAlive = true; });
905
  pingInterval = setInterval(() => {
906
  if (!ws) return;
907
  if (ws.isAlive === false) {
908
+ err('WS 心跳超时');
909
  return ws.terminate();
910
  }
911
  ws.isAlive = false;
 
913
  }, 10000);
914
  });
915
 
916
+ ws.on('message', onClawMessage);
917
  ws.on('close', (code) => {
918
+ log(`🔌 WS断开: ${code}`);
919
+ handleDisconnect(retry);
920
  });
921
  ws.on('error', (e) => err(`WS错误: ${e.message}`));
922
  } catch (e) {
923
  err(`WS初始化失败: ${e.message}`);
924
+ handleDisconnect(retry);
925
  }
926
  }
927
 
928
+ function onClawMessage(raw) {
929
  let msg;
930
+ try { msg = JSON.parse(raw.toString()); } catch { return; }
 
 
 
 
931
 
932
  if (msg.event === 'connect.challenge') {
933
+ const { nonce } = msg.payload;
934
  const signedAt = Date.now();
935
  const payload = [
936
  'v2',
 
941
  'operator.admin,operator.approvals,operator.pairing',
942
  String(signedAt),
943
  OPENCLAW_TOKEN || '',
944
+ nonce
945
  ].join('|');
946
 
947
  const signature = crypto.sign(null, Buffer.from(payload), privateKey);
 
953
  params: {
954
  minProtocol: 3,
955
  maxProtocol: 3,
956
+ client: { id: 'openclaw-control-ui', version: 'dev', platform: 'linux', mode: 'webchat' },
 
 
 
 
 
957
  device: {
958
  id: deviceId,
959
  publicKey: toB64Url(rawPublicKey),
 
974
  }
975
 
976
  if (msg.type === 'res' && msg.id && pendingRequests.has(msg.id)) {
977
+ const fn = pendingRequests.get(msg.id);
978
  pendingRequests.delete(msg.id);
979
+ try { fn(msg.payload); } catch {}
980
  }
981
  }
982
 
983
+ function triggerAgent(cid, uid, senderName, text, isCommand) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
984
  if (!isClawReady || !ws) return;
985
+ const method = isCommand ? 'chat.send' : 'agent';
986
+ const content = isCommand ? text : `${senderName} 说: ${text}`;
987
+ const payload = {
988
  type: 'req',
989
  id: crypto.randomUUID(),
990
+ method,
991
  params: {
992
+ sessionKey: `agent:main:${cid}:${uid}`,
993
+ message: content,
994
  idempotencyKey: crypto.randomUUID(),
995
  deliver: isCommand ? false : undefined
996
  }
997
+ };
998
+ ws.send(JSON.stringify(payload));
999
+ log(`🚀 [发送] ${senderName}: ${String(text).slice(0, 50)}`);
1000
  }
1001
 
1002
  // ============================================================
1003
+ // 7. 频道基准线 + 新接口附件解析
1004
  // ============================================================
1005
 
1006
  async function initChannelBaseline(cid) {
1007
+ const st = channelRuntime.get(cid);
1008
+ if (!st) return;
1009
+
1010
  try {
1011
+ const { data: msgs } = await webuiApi.get(`/api/v1/channels/${cid}/messages?skip=0&limit=1`);
1012
+ if (Array.isArray(msgs) && msgs.length > 0) {
1013
+ st.lastMsgId = msgs[0].id; // 最新一条作基准
1014
+ } else {
1015
+ st.lastMsgId = 'EMPTY_START';
1016
+ }
1017
+ log(`✅ [${st.name}] 基准线: ${st.lastMsgId}`);
1018
  } catch (e) {
1019
+ err(`[${st.name}] 初始化基准失败: ${e.message}`);
1020
  setTimeout(() => initChannelBaseline(cid), 5000);
1021
  }
1022
  }
1023
 
1024
+ async function resolveMessageFiles(cid, msg) {
1025
+ // 兼容新旧接口:
1026
+ // 1) 新版: msg.data === true -> /messages/{id}/data
1027
+ // 2) 旧版: msg.data.files 直接存在
1028
+ let files = [];
1029
 
1030
+ if (msg.data === true) {
1031
+ log(`📡 [Debug] data=true,拉取详情: ${msg.id}`);
 
 
1032
  try {
1033
  const { data } = await webuiApi.get(`/api/v1/channels/${cid}/messages/${msg.id}/data`);
1034
+ if (data && Array.isArray(data.files)) {
1035
+ files = data.files;
1036
+ log(` ✅ 详情返回 files=${files.length}`);
1037
+ } else {
1038
+ log(` ℹ️ 详情无 files`);
1039
+ }
1040
  } catch (e) {
1041
+ err(` ❌ 拉取详情失败: ${e.message}`);
 
1042
  }
1043
+ } else if (msg.data && typeof msg.data === 'object' && Array.isArray(msg.data.files)) {
1044
+ files = msg.data.files;
1045
  }
1046
 
1047
+ return files;
 
 
 
1048
  }
1049
 
1050
+ async function buildAttachmentLinks(cid, files) {
1051
+ const atts = [];
1052
+ if (!files.length) return atts;
 
 
1053
 
1054
+ log(`🔎 [Debug] 开始处理附件 ${files.length} 个`);
1055
 
1056
+ for (const item of files) {
 
1057
  try {
 
1058
  let buffer = null;
1059
+ let mime = item.content_type || item?.file?.meta?.content_type || 'application/octet-stream';
1060
+ let originName = item.name || item?.file?.filename || 'file.bin';
1061
+
1062
+ // A. base64
1063
+ if (item.url && typeof item.url === 'string' && item.url.startsWith('data:')) {
1064
+ const m = item.url.match(/^data:([a-zA-Z0-9/+.-]+);base64,(.+)$/);
1065
+ if (!m) {
1066
+ err(` ❌ Base64 格式无效: ${originName}`);
1067
+ continue;
 
 
 
1068
  }
1069
+ mime = m[1];
1070
+ buffer = Buffer.from(m[2], 'base64');
1071
  }
1072
 
1073
+ // B. file-id 下载(新版主要走这个)
1074
+ if (!buffer) {
1075
+ const fid = item?.file?.id || item?.id;
1076
+ if (!fid) {
1077
+ err(` ❌ 缺少文件ID: ${originName}`);
1078
+ continue;
1079
+ }
1080
+ log(`⬇️ [Debug] 下载文件: ${fid} (${originName})`);
1081
+ const { data } = await fileDownloader.get(`/api/v1/files/${fid}/content`);
1082
  buffer = data;
 
 
 
 
 
 
 
1083
  }
1084
 
1085
+ const safeName = `${Date.now()}_${originName.replace(/[^a-zA-Z0-9._-]/g, '_')}`;
1086
+ const url = await uploadToS3(safeName, buffer, mime);
1087
  if (!url) continue;
1088
 
1089
+ const isImage = (mime || '').startsWith('image/');
1090
+ atts.push(`[${isImage ? '图片' : '文件'}: ${safeName}](${url}) (附件)`);
1091
+ log(` ✅ 附件转链成功: ${safeName}`);
1092
  } catch (e) {
1093
+ err(` ❌ 附件处理异常: ${e.message}`);
1094
  }
1095
  }
1096
 
1097
  return atts;
1098
  }
1099
 
1100
+ // ============================================================
1101
+ // 8. 同步循环(严格不补历史)
1102
+ // ============================================================
1103
 
1104
+ async function syncChannel(cid) {
1105
+ const st = channelRuntime.get(cid);
1106
+ if (!st || !isClawReady) return;
1107
+ if (!st.lastMsgId) return;
1108
+
1109
+ if (st.syncing) {
1110
+ if (Date.now() - (st.syncStartTime || 0) > 60000) {
1111
+ err(`[${st.name}] 同步超时,强制复位`);
1112
+ st.syncing = false;
1113
  } else {
1114
  return;
1115
  }
1116
  }
1117
 
1118
+ st.syncing = true;
1119
+ st.syncStartTime = Date.now();
1120
 
1121
  try {
 
1122
  const { data: msgs } = await webuiApi.get(`/api/v1/channels/${cid}/messages?skip=0&limit=10`);
1123
+ if (!Array.isArray(msgs) || msgs.length === 0) return;
 
 
 
 
 
 
 
 
 
 
 
 
1124
 
1125
+ const sorted = msgs.slice().reverse(); // oldest -> newest
1126
+ const newestId = sorted[sorted.length - 1].id;
1127
+
1128
+ // --- 关键:严格不补历史 ---
1129
+ const startIdx = sorted.findIndex(m => m.id === st.lastMsgId);
1130
+
1131
+ // 首次空基准(或 empty)直接挂到最新,不发���何旧消息
1132
+ if (st.lastMsgId === 'EMPTY_START') {
1133
+ st.lastMsgId = newestId;
1134
+ return;
1135
+ }
1136
+
1137
+ // 基准失效(例如对话删除重建)=> 重置基准并跳过本轮
1138
+ if (startIdx === -1) {
1139
+ if (STRICT_NO_HISTORY_REPLAY) {
1140
+ log(`⛔ [${st.name}] 基准失效,重置到最新,跳过本轮(不补历史)`);
1141
+ st.lastMsgId = newestId;
1142
+ return;
1143
  }
1144
  }
1145
 
1146
+ const newMsgs = sorted.slice(startIdx + 1);
1147
+ if (newMsgs.length === 0) {
1148
+ st.lastMsgId = newestId;
1149
+ return;
1150
+ }
1151
 
1152
+ // 处理“新消息”
1153
+ for (const m of newMsgs) {
1154
+ const sender = m.user?.name || 'User';
1155
+ if (sender.toLowerCase() === BOT_USERNAME.toLowerCase()) continue;
1156
 
1157
+ let txt = String(m.content || m?.data?.content || '')
1158
+ .replace(/<@U:[^>]+>/g, '')
1159
+ .replace(new RegExp(`@${BOT_USERNAME}`, 'ig'), '')
1160
+ .trim();
1161
 
1162
+ const files = await resolveMessageFiles(cid, m);
1163
+ const atts = await buildAttachmentLinks(cid, files);
 
 
1164
 
1165
+ if (atts.length) {
1166
+ txt += (txt ? '\n\n' : '') + atts.join('\n');
1167
+ }
1168
 
1169
+ if (!txt && atts.length > 0) txt = '发送了附件。';
1170
+ if (!txt) continue;
1171
 
1172
+ const isCommand = txt.startsWith('/');
1173
+ triggerAgent(cid, m.user_id, sender, txt, isCommand);
1174
+ await new Promise(r => setTimeout(r, 250));
 
1175
  }
1176
+
1177
+ // 完成本轮后推进基准
1178
+ st.lastMsgId = newestId;
1179
+
1180
  } catch (e) {
1181
+ err(`[${st.name}] 同步失败: ${e.message}`);
1182
  } finally {
1183
+ st.syncing = false;
1184
  }
1185
  }
1186
 
1187
+ async function pollLoop() {
1188
+ const delay = Math.max(Number(POLL_INTERVAL || 5000), 2000);
 
1189
  if (isClawReady) {
1190
+ for (const cid of channelRuntime.keys()) {
 
1191
  await syncChannel(cid);
1192
+ await new Promise(r => setTimeout(r, 100));
1193
  }
1194
  }
1195
+ setTimeout(pollLoop, delay);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1196
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1197
 
1198
  // ============================================================
1199
+ // 9. 启动
1200
  // ============================================================
1201
 
1202
  async function init() {
1203
+ log(`🚀 启动端口: ${PORT}`);
1204
+ log(`📦 Bucket: ${STORAGE_PUBLIC_BUCKET}`);
 
 
1205
  await loadChannelsFromS3();
1206
 
1207
+ // 启动时给每个频道建立基准(不补历史)
1208
  for (const cid of channelRuntime.keys()) {
1209
+ await initChannelBaseline(cid);
1210
  }
1211
 
1212
  connectOpenClaw();
 
 
1213
  setInterval(cleanOldS3Files, 60 * 60 * 1000);
1214
  cleanOldS3Files();
1215
+ pollLoop();
 
1216
  }
1217
 
1218
  app.listen(PORT, () => {
1219
+ init().catch(e => err(`初始化失败: ${e.message}`));
1220
+ });