cc / server1.js
Selinore's picture
Rename server.js to server1.js
331771a verified
Raw History Blame Contribute Delete
47.5 kB
// 'use strict';
// require('dotenv').config();
// const crypto = require('node:crypto');
// const express = require('express');
// const WebSocket = require('ws');
// const axios = require('axios');
// const {
// S3Client,
// PutObjectCommand,
// GetObjectCommand,
// ListObjectsV2Command,
// DeleteObjectCommand
// } = require('@aws-sdk/client-s3');
// // ============================================================
// // 0. 核心配置
// // ============================================================
// const PORT = process.env.PORT || 7860;
// const {
// OPENWEBUI_BASE_URL,
// OPENWEBUI_API_KEY,
// OPENCLAW_WS_URL,
// OPENCLAW_TOKEN,
// BOT_USERNAME = 'openman',
// POLL_INTERVAL = 5000,
// // S3 配置
// STORAGE_EXTERNAL_ENDPOINT,
// STORAGE_PUBLIC_BUCKET,
// STORAGE_REGION,
// STORAGE_ACCESS_KEY_ID,
// STORAGE_SECRET_ACCESS_KEY,
// STORAGE_S3_FORCE_PATH_STYLE
// } = process.env;
// // S3 客户端
// const s3 = new S3Client({
// region: STORAGE_REGION || 'us-east-1',
// endpoint: STORAGE_EXTERNAL_ENDPOINT,
// forcePathStyle: STORAGE_S3_FORCE_PATH_STYLE === 'true',
// credentials: {
// accessKeyId: STORAGE_ACCESS_KEY_ID,
// secretAccessKey: STORAGE_SECRET_ACCESS_KEY
// }
// });
// const S3_PREFIX_CONFIG = 'openclaw/channels.json';
// const S3_PREFIX_UPLOADS = 'openclaw/uploads/';
// // 日志系统
// const logBuffer = [];
// function addLog(type, msg) {
// const time = new Date().toLocaleTimeString('zh-CN', { hour12: false });
// const line = `[${time}] ${type === 'err' ? '❌ ' : ''}${msg}`;
// console[type === 'err' ? 'error' : 'log'](line);
// logBuffer.unshift(line);
// if (logBuffer.length > 100) logBuffer.pop();
// }
// function log(msg) { addLog('info', msg); }
// function err(msg) { addLog('err', msg); }
// const channelRuntime = new Map();
// let ws = null;
// let isClawReady = false;
// let isReconnecting = false;
// let pingInterval = null;
// let reconnectTimer = null;
// const pendingRequests = new Map();
// const webuiApi = axios.create({
// baseURL: OPENWEBUI_BASE_URL,
// headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENWEBUI_API_KEY}` },
// timeout: 10000
// });
// const fileDownloader = axios.create({
// baseURL: OPENWEBUI_BASE_URL,
// headers: { 'Authorization': `Bearer ${OPENWEBUI_API_KEY}` },
// responseType: 'arraybuffer',
// timeout: 30000
// });
// // ============================================================
// // 1. S3 核心功能
// // ============================================================
// async function streamToString(stream) {
// return new Promise((resolve, reject) => {
// const chunks = [];
// stream.on('data', (chunk) => chunks.push(chunk));
// stream.on('error', reject);
// stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
// });
// }
// async function loadChannelsFromS3() {
// try {
// log('☁️ [S3] 读取配置...');
// const command = new GetObjectCommand({ Bucket: STORAGE_PUBLIC_BUCKET, Key: S3_PREFIX_CONFIG });
// const response = await s3.send(command);
// const str = await streamToString(response.Body);
// const data = JSON.parse(str);
// if (data && Array.isArray(data.channels)) {
// data.channels.forEach(ch => {
// if (!channelRuntime.has(ch.id)) {
// channelRuntime.set(ch.id, { id: ch.id, name: ch.name, lastMsgId: null, syncing: false });
// initChannelBaseline(ch.id);
// }
// });
// log(`✅ [S3] 载入 ${data.channels.length} 个频道`);
// }
// } catch (e) {
// if (e.name === 'NoSuchKey') log('ℹ️ [S3] 无配置文件,使用默认');
// else err(`S3读取错误: ${e.message}`);
// }
// }
// async function saveChannelsToS3() {
// try {
// const list = Array.from(channelRuntime.values()).map(s => ({ id: s.id, name: s.name }));
// const json = JSON.stringify({ version: "2.0", savedAt: new Date().toISOString(), channels: list }, null, 2);
// await s3.send(new PutObjectCommand({
// Bucket: STORAGE_PUBLIC_BUCKET,
// Key: S3_PREFIX_CONFIG,
// Body: json,
// ContentType: 'application/json'
// }));
// } catch (e) { err(`S3保存错误: ${e.message}`); }
// }
// async function uploadToS3(filename, buffer, mimeType) {
// const key = S3_PREFIX_UPLOADS + filename;
// try {
// log(`☁️ [Debug] 开始上传S3: ${filename} (${buffer.length} bytes)`);
// await s3.send(new PutObjectCommand({
// Bucket: STORAGE_PUBLIC_BUCKET,
// Key: key,
// Body: buffer,
// ContentType: mimeType || 'application/octet-stream',
// ACL: 'public-read'
// }));
// let baseUrl = STORAGE_EXTERNAL_ENDPOINT;
// if (!baseUrl.endsWith('/')) baseUrl += '/';
// const url = `${baseUrl}${STORAGE_PUBLIC_BUCKET}/${key}`;
// log(`✅ [Debug] S3上传成功: ${url}`);
// return url;
// } catch (e) {
// err(`❌ S3上传失败 [${filename}]: ${e.message}`);
// return null;
// }
// }
// async function cleanOldS3Files() {
// log('🧹 [S3] 检查过期文件...');
// try {
// const threeDaysAgo = Date.now() - (3 * 24 * 60 * 60 * 1000);
// let continuationToken = null;
// let deletedCount = 0;
// do {
// const result = await s3.send(new ListObjectsV2Command({
// Bucket: STORAGE_PUBLIC_BUCKET,
// Prefix: S3_PREFIX_UPLOADS,
// ContinuationToken: continuationToken
// }));
// if (result.Contents) {
// for (const item of result.Contents) {
// if (item.LastModified && item.LastModified.getTime() < threeDaysAgo) {
// await s3.send(new DeleteObjectCommand({ Bucket: STORAGE_PUBLIC_BUCKET, Key: item.Key }));
// deletedCount++;
// }
// }
// }
// continuationToken = result.NextContinuationToken;
// } while (continuationToken);
// if(deletedCount > 0) log(`🗑️ 已清理 ${deletedCount} 个旧文件`);
// } catch (e) { err(`清理失败: ${e.message}`); }
// }
// // ============================================================
// // 2. Web 界面
// // ============================================================
// const app = express();
// app.use(express.json());
// const HTML_PAGE = `
// <!DOCTYPE html>
// <html lang="zh">
// <head>
// <meta charset="UTF-8"><title>Bridge Console (S3)</title>
// <style>
// body{font-family:system-ui;background:#f3f4f6;padding:20px;max-width:900px;margin:0 auto}
// .card{background:#fff;padding:20px;border-radius:12px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.1)}
// .bad{background:#fee2e2;color:#991b1b;padding:4px 8px;border-radius:4px}
// .ok{background:#d1fae5;color:#065f46;padding:4px 8px;border-radius:4px}
// #log-viewer{background:#111827;color:#10b981;padding:15px;border-radius:8px;height:400px;overflow-y:auto;font-family:monospace;font-size:12px;white-space:pre-wrap}
// .log-entry{border-bottom:1px solid #1f2937;padding:2px 0}
// input{padding:8px;border:1px solid #ddd;border-radius:4px}
// button{padding:8px 16px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer}
// </style>
// </head>
// <body>
// <div class="card" style="display:flex;justify-content:space-between">
// <h1 style="margin:0">Bridge Console <small style="font-size:0.5em;background:#e0f2fe;padding:2px">S3 + NewAPI</small></h1>
// <div>Claw: <span id="st" class="bad">...</span></div>
// </div>
// <div class="card">
// <div style="display:flex;gap:10px;margin-bottom:10px">
// <input id="nid" placeholder="Channel ID" style="flex:1"><input id="nnm" placeholder="Name" style="flex:1">
// <button onclick="add()">Add</button>
// </div>
// <div id="list"></div>
// </div>
// <div class="card"><div id="log-viewer">Logs...</div></div>
// <script>
// const $ = id => document.getElementById(id);
// async function r(){
// try {
// const h = await(await fetch('/health')).json();
// $('st').innerText=h.wsConnected?'Connected':'Disconnected';
// $('st').className=h.wsConnected?'ok':'bad';
// const c = await(await fetch('/api/channels')).json();
// $('list').innerHTML = '<table style="width:100%">'+c.map(x=>\`<tr><td>\${x.name}</td><td>\${x.id}</td><td>\${x.syncing?'🔄':'💤'}</td><td><button onclick="del('\${x.id}')" style="background:#dc2626">Del</button></td></tr>\`).join('')+'</table>';
// const l = await(await fetch('/api/logs')).json();
// $('log-viewer').innerHTML = l.map(t=>\`<div class="log-entry">\${t.replace(/</g,'&lt;')}</div>\`).join('');
// } catch {}
// }
// async function add(){ await fetch('/api/channels',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:$('nid').value,name:$('nnm').value})}); $('nid').value=''; r(); }
// async function del(id){ if(confirm('Del?')) await fetch('/api/channels/'+id,{method:'DELETE'}); r(); }
// setInterval(r,3000); r();
// </script>
// </body></html>
// `;
// app.get('/', (req, res) => res.send(HTML_PAGE));
// app.get('/api/channels', (req, res) => res.json(Array.from(channelRuntime.values())));
// app.get('/api/logs', (req, res) => res.json(logBuffer));
// app.post('/api/channels', async (req, res) => {
// const { id, name } = req.body;
// if (!channelRuntime.has(id)) {
// channelRuntime.set(id, { id, name: name||id, lastMsgId: null, syncing: false });
// await saveChannelsToS3();
// initChannelBaseline(id);
// }
// res.json({success:true});
// });
// app.delete('/api/channels/:id', async (req, res) => {
// if (channelRuntime.delete(req.params.id)) await saveChannelsToS3();
// res.json({success:true});
// });
// app.get('/health', (req, res) => res.json({ status:'ok', wsConnected:isClawReady }));
// app.listen(PORT, async () => {
// log(`🚀 服务启动: ${PORT}`);
// await init();
// });
// // ============================================================
// // 3. 业务逻辑
// // ============================================================
// async function init() {
// try {
// await loadChannelsFromS3();
// connectOpenClaw();
// setInterval(cleanOldS3Files, 3600 * 1000);
// cleanOldS3Files();
// } catch (e) { err(e.message); }
// }
// async function initChannelBaseline(id) {
// const state = channelRuntime.get(id);
// if (!state) return;
// try {
// const res = await webuiApi.get(`/api/v1/channels/${id}/messages?limit=1`);
// state.lastMsgId = (res.data && res.data.length > 0) ? res.data[0].id : "EMPTY_START";
// log(`✅ [${state.name}] 就绪`);
// } catch { setTimeout(() => initChannelBaseline(id), 5000); }
// }
// // WS 连接逻辑
// const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
// const rawPublicKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(12);
// const deviceId = crypto.createHash('sha256').update(rawPublicKey).digest('hex');
// const toB64Url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
// function cleanupConnection() {
// if (pingInterval) clearInterval(pingInterval);
// if (ws) { ws.removeAllListeners(); try { ws.terminate(); } catch {} ws = null; }
// isClawReady = false;
// for (const state of channelRuntime.values()) state.syncing = false;
// }
// function connectOpenClaw(retryCount = 0) {
// if (isReconnecting || ws) return;
// isReconnecting = true;
// log(`🔌 连接 OpenClaw (${retryCount + 1})...`);
// try {
// ws = new WebSocket(OPENCLAW_WS_URL.replace(/\/$/, '') + '/ws');
// ws.on('open', () => {
// log('✅ WS 握手成功');
// isReconnecting = false;
// ws.isAlive = true;
// ws.on('pong', () => { ws.isAlive = true; });
// pingInterval = setInterval(() => {
// if (ws.isAlive === false) { err('心跳超时'); return ws.terminate(); }
// ws.isAlive = false;
// ws.ping();
// }, 10000);
// });
// ws.on('message', handleClawMessage);
// ws.on('close', (code) => { log(`🔌 断开 (${code})`); handleDisconnect(retryCount); });
// ws.on('error', (e) => err(`WS错误: ${e.message}`));
// } catch (e) { err(e.message); handleDisconnect(retryCount); }
// }
// function handleDisconnect(retryCount) {
// cleanupConnection();
// isReconnecting = false;
// const delay = Math.min(5000 * Math.pow(1.5, retryCount), 60000);
// if (reconnectTimer) clearTimeout(reconnectTimer);
// reconnectTimer = setTimeout(() => { connectOpenClaw(retryCount + 1); }, delay);
// }
// function handleClawMessage(data) {
// let msg;
// try { msg = JSON.parse(data.toString()); } catch { return; }
// if (msg.event === 'connect.challenge') {
// const { nonce } = msg.payload;
// const signedAt = Date.now();
// const payload = ['v2', deviceId, 'openclaw-control-ui', 'webchat', 'operator', "operator.admin,operator.approvals,operator.pairing", String(signedAt), OPENCLAW_TOKEN||'', nonce].join('|');
// const signature = crypto.sign(null, Buffer.from(payload), privateKey);
// ws.send(JSON.stringify({
// type: 'req', id: crypto.randomUUID(), method: 'connect',
// params: {
// minProtocol: 3, maxProtocol: 3,
// client: { id: 'openclaw-control-ui', version: 'dev', platform: 'Win32', mode: 'webchat' },
// device: { id: deviceId, publicKey: toB64Url(rawPublicKey), signature: toB64Url(signature), signedAt, nonce },
// auth: { token: OPENCLAW_TOKEN },
// role: 'operator', scopes: ["operator.admin","operator.approvals","operator.pairing"]
// }
// }));
// }
// if (msg.payload?.type === 'hello-ok') { log('🎉 OpenClaw 认证成功'); isClawReady = true; }
// if (msg.type === 'res' && msg.id && pendingRequests.has(msg.id)) {
// const handler = pendingRequests.get(msg.id);
// if (Array.isArray(handler)) handler[0](msg.payload);
// pendingRequests.delete(msg.id);
// }
// }
// function fetchClawHistory(cid, uid) {
// return new Promise((resolve) => {
// if (!isClawReady) return resolve([]);
// const id = crypto.randomUUID();
// const t = setTimeout(() => { pendingRequests.delete(id); resolve([]); }, 5000);
// pendingRequests.set(id, [(p) => { clearTimeout(t); resolve(p.messages || []); }, () => resolve([])]);
// try { ws.send(JSON.stringify({ type:'req', id, method:'chat.history', params:{ sessionKey:`agent:main:${cid}`, limit:5 } })); }
// // try { ws.send(JSON.stringify({ type:'req', id, method:'chat.history', params:{ sessionKey:`agent:main:${cid}:${uid}`, limit:5 } })); }
// catch { resolve([]); }
// });
// }
// function triggerAgent(cid, uid, name, text, isCommand) {
// if (!isClawReady) return;
// const finalMsg = isCommand ? text : `${name} 说: ${text}`;
// ws.send(JSON.stringify({
// type: 'req', id: crypto.randomUUID(), method: isCommand ? 'chat.send' : 'agent',
// params: { sessionKey: `agent:main:${cid}`, message: finalMsg, idempotencyKey: crypto.randomUUID(), deliver: isCommand?false:undefined }
// // params: { sessionKey: `agent:main:${cid}:${uid}`, message: finalMsg, idempotencyKey: crypto.randomUUID(), deliver: isCommand?false:undefined }
// }));
// log(`🚀 [发送] ${name}: ${text.slice(0,20)}...`);
// }
// // ============================================================
// // 4. 同步逻辑 (重点修改:适配新接口)
// // ============================================================
// async function syncChannel(cid) {
// const state = channelRuntime.get(cid);
// if (!state || !state.lastMsgId || !isClawReady) return;
// if (state.syncing) {
// if (Date.now() - (state.syncStartTime || 0) > 60000) { log(`⚠️ [${state.name}] 超时复位`); state.syncing = false; }
// else return;
// }
// state.syncing = true;
// state.syncStartTime = Date.now();
// try {
// // Step 1: 获取消息列表
// const { data: msgs } = await webuiApi.get(`/api/v1/channels/${cid}/messages?skip=0&limit=10`);
// if (!msgs?.length) throw new Error("No messages");
// // --- 1. 回信检测 (保持不变) ---
// const webuiFingerprints = new Set(msgs.map(m => (m.content || m.data?.content || '').trim()).filter(Boolean));
// const activeUsers = new Map();
// msgs.slice(0, 5).forEach(m => { if(m.user_id) activeUsers.set(m.user_id, {id: m.user_id, name: m.user?.name||'User'}); });
// for (const u of activeUsers.values()) {
// await new Promise(r => setTimeout(r, 200));
// const history = await fetchClawHistory(cid, u.id);
// const replies = history.filter(h => h.role === 'assistant').slice(-2);
// for (const r of replies) {
// const txt = r.content?.[0]?.text?.trim();
// if (txt && !webuiFingerprints.has(txt)) {
// log(`📥 [回复] -> ${u.name}`);
// await webuiApi.post(`/api/v1/channels/${cid}/messages/post`, { content: txt, data:{files:[]}, reply_to_id:null });
// webuiFingerprints.add(txt);
// }
// }
// }
// // --- 2. 新消息处理 (WebUI -> Claw) ---
// const sorted = msgs.reverse();
// const startIdx = sorted.findIndex(m => m.id === state.lastMsgId);
// const newMsgs = (startIdx !== -1) ? sorted.slice(startIdx + 1) : (state.lastMsgId !== "EMPTY_START" ? sorted : []);
// if (newMsgs.length > 0) {
// state.lastMsgId = sorted[sorted.length - 1].id;
// for (const m of newMsgs) {
// const sender = m.user?.name || '';
// if (sender.toLowerCase() === BOT_USERNAME.toLowerCase()) continue;
// let txt = (m.content || m.data?.content || '').replace(/<@U:[^>]+>/g,'').replace(new RegExp(`@${BOT_USERNAME}`,'gi'),'').trim();
// let atts = [];
// // ==========================================
// // 🔴 核心修复:适配新旧两种数据接口
// // ==========================================
// let rawFiles = [];
// // 情况A:新版接口,data 为 true,需要二次请求
// if (m.data === true) {
// log(`📡 [Debug] 发现懒加载数据,正在请求详情接口: ${m.id}`);
// try {
// const { data: detailData } = await webuiApi.get(`/api/v1/channels/${cid}/messages/${m.id}/data`);
// if (detailData && detailData.files) {
// rawFiles = detailData.files;
// log(` ✅ 获取到 ${rawFiles.length} 个文件信息`);
// }
// } catch (e) {
// err(` ❌ 获取详情失败: ${e.message}`);
// }
// }
// // 情况B:旧版接口或 Base64 粘贴,data 直接是对象
// else if (typeof m.data === 'object' && m.data?.files) {
// rawFiles = m.data.files;
// }
// // ==========================================
// // 🔵 统一文件处理 (下载 -> S3)
// // ==========================================
// if (rawFiles.length > 0) {
// log(`🔎 [Debug] 开始处理 ${rawFiles.length} 个附件...`);
// for (const fItem of rawFiles) {
// try {
// let buffer, filename, mimeType;
// // 兼容性处理:有时是 fItem.file,有时直接是 fItem
// const f = fItem.file || fItem;
// // 1. Base64 图片 (直接粘贴)
// if (fItem.url && fItem.url.startsWith('data:')) {
// log(`📸 [Debug] 解析 Base64 图片...`);
// const matches = fItem.url.match(/^data:([a-zA-Z0-9/+-]+);base64,(.+)$/);
// if (matches) {
// mimeType = matches[1];
// const extMap = {'image/jpeg':'jpg', 'image/png':'png', 'image/gif':'gif', 'image/webp':'webp'};
// const ext = extMap[mimeType] || 'bin';
// buffer = Buffer.from(matches[2], 'base64');
// filename = `img_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
// }
// }
// // 2. 实体文件 (ID 下载) - 包含上传的图片和文件
// else if (f.id) {
// log(`⬇️ [Debug] 下载文件 ID: ${f.id} (${f.filename})...`);
// const { data } = await fileDownloader.get(`/api/v1/files/${f.id}/content`);
// buffer = data;
// filename = `${Date.now()}_${(f.filename || 'file').replace(/[^a-zA-Z0-9.-]/g, '_')}`;
// mimeType = f.content_type || fItem.content_type || 'application/octet-stream';
// }
// // 3. 上传到 S3
// if (buffer && filename) {
// const s3Url = await uploadToS3(filename, buffer, mimeType);
// if (s3Url) {
// // 根据类型生成不同的 Markdown
// const isImage = mimeType?.startsWith('image/');
// atts.push(`[${isImage?'图片':'文件'}: ${filename}](${s3Url}) (附件)`);
// log(` ✅ 转链成功: ${filename}`);
// }
// }
// } catch (e) {
// err(` ❌ 附件处理失败: ${e.message}`);
// }
// }
// }
// if (atts.length) txt += (txt ? '\n\n' : '') + atts.join('\n');
// const isCommand = txt.trim().startsWith('/');
// if (txt || atts.length > 0) {
// if (!txt && atts.length > 0) txt = "发送了附件。";
// await new Promise(r => setTimeout(r, 500));
// triggerAgent(cid, m.user_id, sender, txt, isCommand);
// }
// }
// }
// } catch (e) {
// if(e.message!=="No messages") err(`[${state.name}] 跳过: ${e.message}`);
// } finally { state.syncing = false; }
// }
// async function loop() {
// const delay = Math.max(parseInt(POLL_INTERVAL || 5000), 2000);
// if (isClawReady) {
// for (const cid of channelRuntime.keys()) {
// await syncChannel(cid);
// await new Promise(r => setTimeout(r, 200));
// }
// }
// setTimeout(loop, delay);
// }
// loop();
'use strict';
require('dotenv').config();
const crypto = require('node:crypto');
const express = require('express');
const WebSocket = require('ws');
const axios = require('axios');
const {
S3Client,
PutObjectCommand,
GetObjectCommand,
ListObjectsV2Command,
DeleteObjectCommand
} = require('@aws-sdk/client-s3');
// ============================================================
// 0. 核心配置
// ============================================================
const PORT = process.env.PORT || 7860;
const {
OPENWEBUI_BASE_URL,
OPENWEBUI_API_KEY,
OPENCLAW_WS_URL,
OPENCLAW_TOKEN,
BOT_USERNAME = 'openman',
POLL_INTERVAL = 5000,
// S3 配置
STORAGE_EXTERNAL_ENDPOINT,
STORAGE_PUBLIC_BUCKET,
STORAGE_REGION,
STORAGE_ACCESS_KEY_ID,
STORAGE_SECRET_ACCESS_KEY,
STORAGE_S3_FORCE_PATH_STYLE
} = process.env;
// S3 客户端
const s3 = new S3Client({
region: STORAGE_REGION || 'us-east-1',
endpoint: STORAGE_EXTERNAL_ENDPOINT,
forcePathStyle: STORAGE_S3_FORCE_PATH_STYLE === 'true',
credentials: {
accessKeyId: STORAGE_ACCESS_KEY_ID,
secretAccessKey: STORAGE_SECRET_ACCESS_KEY
}
});
const S3_PREFIX_CONFIG = 'openclaw/channels.json';
const S3_PREFIX_UPLOADS = 'openclaw/uploads/';
// 日志系统
const logBuffer = [];
function addLog(type, msg) {
const time = new Date().toLocaleTimeString('zh-CN', { hour12: false });
const line = `[${time}] ${type === 'err' ? '❌ ' : ''}${msg}`;
console[type === 'err' ? 'error' : 'log'](line);
logBuffer.unshift(line);
if (logBuffer.length > 100) logBuffer.pop();
}
function log(msg) { addLog('info', msg); }
function err(msg) { addLog('err', msg); }
const channelRuntime = new Map();
let ws = null;
let isClawReady = false;
let isReconnecting = false;
let pingInterval = null;
let reconnectTimer = null;
const pendingRequests = new Map();
const webuiApi = axios.create({
baseURL: OPENWEBUI_BASE_URL,
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${OPENWEBUI_API_KEY}` },
timeout: 10000
});
const fileDownloader = axios.create({
baseURL: OPENWEBUI_BASE_URL,
headers: { 'Authorization': `Bearer ${OPENWEBUI_API_KEY}` },
responseType: 'arraybuffer',
timeout: 30000
});
// ============================================================
// 1. S3 核心功能
// ============================================================
async function streamToString(stream) {
return new Promise((resolve, reject) => {
const chunks = [];
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('error', reject);
stream.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')));
});
}
async function loadChannelsFromS3() {
try {
log('☁️ [S3] 读取配置...');
const command = new GetObjectCommand({ Bucket: STORAGE_PUBLIC_BUCKET, Key: S3_PREFIX_CONFIG });
const response = await s3.send(command);
const str = await streamToString(response.Body);
const data = JSON.parse(str);
if (data && Array.isArray(data.channels)) {
data.channels.forEach(ch => {
if (!channelRuntime.has(ch.id)) {
// 初始化时,lastMsgId 设为 null,稍后会由 initChannelBaseline 强制设为最新
channelRuntime.set(ch.id, { id: ch.id, name: ch.name, lastMsgId: null, syncing: false });
initChannelBaseline(ch.id);
}
});
log(`✅ [S3] 载入 ${data.channels.length} 个频道`);
}
} catch (e) {
if (e.name === 'NoSuchKey') log('ℹ️ [S3] 无配置文件,使用默认');
else err(`S3读取错误: ${e.message}`);
}
}
async function saveChannelsToS3() {
try {
const list = Array.from(channelRuntime.values()).map(s => ({ id: s.id, name: s.name }));
const json = JSON.stringify({ version: "2.0", savedAt: new Date().toISOString(), channels: list }, null, 2);
await s3.send(new PutObjectCommand({
Bucket: STORAGE_PUBLIC_BUCKET,
Key: S3_PREFIX_CONFIG,
Body: json,
ContentType: 'application/json'
}));
} catch (e) { err(`S3保存错误: ${e.message}`); }
}
async function uploadToS3(filename, buffer, mimeType) {
const key = S3_PREFIX_UPLOADS + filename;
try {
log(`☁️ [Debug] 开始上传S3: ${filename} (${buffer.length} bytes)`);
await s3.send(new PutObjectCommand({
Bucket: STORAGE_PUBLIC_BUCKET,
Key: key,
Body: buffer,
ContentType: mimeType || 'application/octet-stream',
ACL: 'public-read'
}));
let baseUrl = STORAGE_EXTERNAL_ENDPOINT;
if (!baseUrl.endsWith('/')) baseUrl += '/';
const url = `${baseUrl}${STORAGE_PUBLIC_BUCKET}/${key}`;
log(`✅ [Debug] S3上传成功: ${url}`);
return url;
} catch (e) {
err(`❌ S3上传失败 [${filename}]: ${e.message}`);
return null;
}
}
async function cleanOldS3Files() {
log('🧹 [S3] 检查过期文件...');
try {
const threeDaysAgo = Date.now() - (3 * 24 * 60 * 60 * 1000);
let continuationToken = null;
let deletedCount = 0;
do {
const result = await s3.send(new ListObjectsV2Command({
Bucket: STORAGE_PUBLIC_BUCKET,
Prefix: S3_PREFIX_UPLOADS,
ContinuationToken: continuationToken
}));
if (result.Contents) {
for (const item of result.Contents) {
if (item.LastModified && item.LastModified.getTime() < threeDaysAgo) {
await s3.send(new DeleteObjectCommand({ Bucket: STORAGE_PUBLIC_BUCKET, Key: item.Key }));
deletedCount++;
}
}
}
continuationToken = result.NextContinuationToken;
} while (continuationToken);
if(deletedCount > 0) log(`🗑️ 已清理 ${deletedCount} 个旧文件`);
} catch (e) { err(`清理失败: ${e.message}`); }
}
// ============================================================
// 2. Web 界面
// ============================================================
const app = express();
app.use(express.json());
const HTML_PAGE = `
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8"><title>Bridge Console (No History)</title>
<style>
body{font-family:system-ui;background:#f3f4f6;padding:20px;max-width:900px;margin:0 auto}
.card{background:#fff;padding:20px;border-radius:12px;margin-bottom:20px;box-shadow:0 1px 3px rgba(0,0,0,.1)}
.bad{background:#fee2e2;color:#991b1b;padding:4px 8px;border-radius:4px}
.ok{background:#d1fae5;color:#065f46;padding:4px 8px;border-radius:4px}
#log-viewer{background:#111827;color:#10b981;padding:15px;border-radius:8px;height:400px;overflow-y:auto;font-family:monospace;font-size:12px;white-space:pre-wrap}
.log-entry{border-bottom:1px solid #1f2937;padding:2px 0}
input{padding:8px;border:1px solid #ddd;border-radius:4px}
button{padding:8px 16px;background:#2563eb;color:#fff;border:none;border-radius:4px;cursor:pointer}
</style>
</head>
<body>
<div class="card" style="display:flex;justify-content:space-between">
<h1 style="margin:0">Bridge Console <small style="font-size:0.5em;background:#e0f2fe;padding:2px">No History Mode</small></h1>
<div>Claw: <span id="st" class="bad">...</span></div>
</div>
<div class="card">
<div style="display:flex;gap:10px;margin-bottom:10px">
<input id="nid" placeholder="Channel ID" style="flex:1"><input id="nnm" placeholder="Name" style="flex:1">
<button onclick="add()">Add</button>
</div>
<div id="list"></div>
</div>
<div class="card"><div id="log-viewer">Logs...</div></div>
<script>
const $ = id => document.getElementById(id);
async function r(){
try {
const h = await(await fetch('/health')).json();
$('st').innerText=h.wsConnected?'Connected':'Disconnected';
$('st').className=h.wsConnected?'ok':'bad';
const c = await(await fetch('/api/channels')).json();
$('list').innerHTML = '<table style="width:100%">'+c.map(x=>\`<tr><td>\${x.name}</td><td>\${x.id}</td><td>\${x.syncing?'🔄':'💤'}</td><td><button onclick="del('\${x.id}')" style="background:#dc2626">Del</button></td></tr>\`).join('')+'</table>';
const l = await(await fetch('/api/logs')).json();
$('log-viewer').innerHTML = l.map(t=>\`<div class="log-entry">\${t.replace(/</g,'&lt;')}</div>\`).join('');
} catch {}
}
async function add(){ await fetch('/api/channels',{method:'POST',headers:{'content-type':'application/json'},body:JSON.stringify({id:$('nid').value,name:$('nnm').value})}); $('nid').value=''; r(); }
async function del(id){ if(confirm('Del?')) await fetch('/api/channels/'+id,{method:'DELETE'}); r(); }
setInterval(r,3000); r();
</script>
</body></html>
`;
app.get('/', (req, res) => res.send(HTML_PAGE));
app.get('/api/channels', (req, res) => res.json(Array.from(channelRuntime.values())));
app.get('/api/logs', (req, res) => res.json(logBuffer));
app.post('/api/channels', async (req, res) => {
const { id, name } = req.body;
if (!channelRuntime.has(id)) {
channelRuntime.set(id, { id, name: name||id, lastMsgId: null, syncing: false });
await saveChannelsToS3();
initChannelBaseline(id);
}
res.json({success:true});
});
app.delete('/api/channels/:id', async (req, res) => {
if (channelRuntime.delete(req.params.id)) await saveChannelsToS3();
res.json({success:true});
});
app.get('/health', (req, res) => res.json({ status:'ok', wsConnected:isClawReady }));
app.listen(PORT, async () => {
log(`🚀 服务启动: ${PORT}`);
await init();
});
// ============================================================
// 3. 业务逻辑
// ============================================================
async function init() {
try {
await loadChannelsFromS3();
connectOpenClaw();
setInterval(cleanOldS3Files, 3600 * 1000);
cleanOldS3Files();
} catch (e) { err(e.message); }
}
async function initChannelBaseline(id) {
const state = channelRuntime.get(id);
if (!state) return;
try {
// 🛑 核心保护:启动时直接获取最新一条消息 ID。
// 不管以前发生过什么,我们只认这一刻最新的消息作为起点。
const res = await webuiApi.get(`/api/v1/channels/${id}/messages?limit=1`);
if (res.data && res.data.length > 0) {
state.lastMsgId = res.data[0].id; // 锁定最新ID
log(`✅ [${state.name}] 初始化完成,锚点锁定: ${state.lastMsgId} (不发送历史)`);
} else {
state.lastMsgId = "EMPTY_START";
log(`✅ [${state.name}] 频道为空,等待第一条消息...`);
}
} catch { setTimeout(() => initChannelBaseline(id), 5000); }
}
// WS 连接逻辑
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
const rawPublicKey = publicKey.export({ type: 'spki', format: 'der' }).subarray(12);
const deviceId = crypto.createHash('sha256').update(rawPublicKey).digest('hex');
const toB64Url = (buf) => buf.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
function cleanupConnection() {
if (pingInterval) clearInterval(pingInterval);
if (ws) { ws.removeAllListeners(); try { ws.terminate(); } catch {} ws = null; }
isClawReady = false;
for (const state of channelRuntime.values()) state.syncing = false;
}
function connectOpenClaw(retryCount = 0) {
if (isReconnecting || ws) return;
isReconnecting = true;
log(`🔌 连接 OpenClaw (${retryCount + 1})...`);
try {
ws = new WebSocket(OPENCLAW_WS_URL.replace(/\/$/, '') + '/ws');
ws.on('open', () => {
log('✅ WS 握手成功');
isReconnecting = false;
ws.isAlive = true;
ws.on('pong', () => { ws.isAlive = true; });
pingInterval = setInterval(() => {
if (ws.isAlive === false) { err('心跳超时'); return ws.terminate(); }
ws.isAlive = false;
ws.ping();
}, 10000);
});
ws.on('message', handleClawMessage);
ws.on('close', (code) => { log(`🔌 断开 (${code})`); handleDisconnect(retryCount); });
ws.on('error', (e) => err(`WS错误: ${e.message}`));
} catch (e) { err(e.message); handleDisconnect(retryCount); }
}
function handleDisconnect(retryCount) {
cleanupConnection();
isReconnecting = false;
const delay = Math.min(5000 * Math.pow(1.5, retryCount), 60000);
if (reconnectTimer) clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(() => { connectOpenClaw(retryCount + 1); }, delay);
}
function handleClawMessage(data) {
let msg;
try { msg = JSON.parse(data.toString()); } catch { return; }
if (msg.event === 'connect.challenge') {
const { nonce } = msg.payload;
const signedAt = Date.now();
const payload = ['v2', deviceId, 'openclaw-control-ui', 'webchat', 'operator', "operator.admin,operator.approvals,operator.pairing", String(signedAt), OPENCLAW_TOKEN||'', nonce].join('|');
const signature = crypto.sign(null, Buffer.from(payload), privateKey);
ws.send(JSON.stringify({
type: 'req', id: crypto.randomUUID(), method: 'connect',
params: {
minProtocol: 3, maxProtocol: 3,
client: { id: 'openclaw-control-ui', version: 'dev', platform: 'Win32', mode: 'webchat' },
device: { id: deviceId, publicKey: toB64Url(rawPublicKey), signature: toB64Url(signature), signedAt, nonce },
auth: { token: OPENCLAW_TOKEN },
role: 'operator', scopes: ["operator.admin","operator.approvals","operator.pairing"]
}
}));
}
if (msg.payload?.type === 'hello-ok') { log('🎉 OpenClaw 认证成功'); isClawReady = true; }
if (msg.type === 'res' && msg.id && pendingRequests.has(msg.id)) {
const handler = pendingRequests.get(msg.id);
if (Array.isArray(handler)) handler[0](msg.payload);
pendingRequests.delete(msg.id);
}
}
function fetchClawHistory(cid, uid) {
return new Promise((resolve) => {
if (!isClawReady) return resolve([]);
const id = crypto.randomUUID();
const t = setTimeout(() => { pendingRequests.delete(id); resolve([]); }, 5000);
pendingRequests.set(id, [(p) => { clearTimeout(t); resolve(p.messages || []); }, () => resolve([])]);
try { ws.send(JSON.stringify({ type:'req', id, method:'chat.history', params:{ sessionKey:`agent:main:${cid}`, limit:5 } })); }
catch { resolve([]); }
});
}
function triggerAgent(cid, uid, name, text, isCommand) {
if (!isClawReady) return;
const finalMsg = isCommand ? text : `${name} 说: ${text}`;
ws.send(JSON.stringify({
type: 'req', id: crypto.randomUUID(), method: isCommand ? 'chat.send' : 'agent',
params: { sessionKey: `agent:main:${cid}`, message: finalMsg, idempotencyKey: crypto.randomUUID(), deliver: isCommand?false:undefined }
}));
log(`🚀 [发送] ${name}: ${text.slice(0,20)}...`);
}
// ============================================================
// 4. 同步逻辑 (严防历史消息泄漏版)
// ============================================================
async function syncChannel(cid) {
const state = channelRuntime.get(cid);
if (!state || !state.lastMsgId || !isClawReady) return;
if (state.syncing) {
if (Date.now() - (state.syncStartTime || 0) > 60000) { log(`⚠️ [${state.name}] 超时复位`); state.syncing = false; }
else return;
}
state.syncing = true;
state.syncStartTime = Date.now();
try {
const { data: msgs } = await webuiApi.get(`/api/v1/channels/${cid}/messages?skip=0&limit=10`);
if (!msgs?.length) throw new Error("No messages");
// --- 1. 回信检测 (保持不变) ---
const webuiFingerprints = new Set(msgs.map(m => (m.content || m.data?.content || '').trim()).filter(Boolean));
const activeUsers = new Map();
msgs.slice(0, 5).forEach(m => { if(m.user_id) activeUsers.set(m.user_id, {id: m.user_id, name: m.user?.name||'User'}); });
for (const u of activeUsers.values()) {
await new Promise(r => setTimeout(r, 200));
const history = await fetchClawHistory(cid, u.id);
const replies = history.filter(h => h.role === 'assistant').slice(-2);
for (const r of replies) {
const txt = r.content?.[0]?.text?.trim();
if (txt && !webuiFingerprints.has(txt)) {
log(`📥 [回复] -> ${u.name}`);
await webuiApi.post(`/api/v1/channels/${cid}/messages/post`, { content: txt, data:{files:[]}, reply_to_id:null });
webuiFingerprints.add(txt);
}
}
}
// --- 2. 新消息处理 (WebUI -> Claw) ---
const sorted = msgs.reverse();
// 🔴 关键修复逻辑:计算新消息
let newMsgs = [];
const startIdx = sorted.findIndex(m => m.id === state.lastMsgId);
if (state.lastMsgId === "EMPTY_START") {
// 频道刚开始是空的,现在有消息了,全部视为新消息
newMsgs = sorted;
} else if (startIdx !== -1) {
// 找到了上次的锚点,正常截取之后的消息
newMsgs = sorted.slice(startIdx + 1);
} else {
// ⚠️ 严重情况:找不到锚点 (说明被删除了)。
// 绝对不能发送 sorted,否则就是炸弹。
// 策略:重置锚点为当前最新,静默跳过。
if (sorted.length > 0) {
const newestId = sorted[sorted.length - 1].id;
log(`⚠️ [${state.name}] 锚点丢失(已删),重置锚点 -> ${newestId},跳过历史同步。`);
state.lastMsgId = newestId;
}
newMsgs = []; // 强制为空
}
if (newMsgs.length > 0) {
state.lastMsgId = sorted[sorted.length - 1].id; // 更新锚点
for (const m of newMsgs) {
const sender = m.user?.name || '';
if (sender.toLowerCase() === BOT_USERNAME.toLowerCase()) continue;
let txt = (m.content || m.data?.content || '').replace(/<@U:[^>]+>/g,'').replace(new RegExp(`@${BOT_USERNAME}`,'gi'),'').trim();
let atts = [];
// 附件处理 (懒加载兼容逻辑)
let rawFiles = [];
if (m.data === true) {
log(`📡 [Debug] 发现懒加载数据,请求详情: ${m.id}`);
try {
const { data: detailData } = await webuiApi.get(`/api/v1/channels/${cid}/messages/${m.id}/data`);
if (detailData && detailData.files) rawFiles = detailData.files;
} catch (e) { err(` ❌ 详情获取失败: ${e.message}`); }
} else if (typeof m.data === 'object' && m.data?.files) {
rawFiles = m.data.files;
}
if (rawFiles.length > 0) {
log(`🔎 [Debug] 处理 ${rawFiles.length} 个附件...`);
for (const fItem of rawFiles) {
try {
let buffer, filename, mimeType;
const f = fItem.file || fItem;
// Base64
if (fItem.url && fItem.url.startsWith('data:')) {
const matches = fItem.url.match(/^data:([a-zA-Z0-9/+-]+);base64,(.+)$/);
if (matches) {
mimeType = matches[1];
const extMap = {'image/jpeg':'jpg', 'image/png':'png', 'image/gif':'gif', 'image/webp':'webp'};
const ext = extMap[mimeType] || 'bin';
buffer = Buffer.from(matches[2], 'base64');
filename = `img_${Date.now()}_${Math.random().toString(36).slice(2)}.${ext}`;
}
}
// ID 下载
else if (f.id) {
log(`⬇️ [Debug] 下载文件: ${f.filename}`);
const { data } = await fileDownloader.get(`/api/v1/files/${f.id}/content`);
buffer = data;
filename = `${Date.now()}_${(f.filename || 'file').replace(/[^a-zA-Z0-9.-]/g, '_')}`;
mimeType = f.content_type || fItem.content_type || 'application/octet-stream';
}
if (buffer && filename) {
const s3Url = await uploadToS3(filename, buffer, mimeType);
if (s3Url) {
const isImage = mimeType?.startsWith('image/');
atts.push(`[${isImage?'图片':'文件'}: ${filename}](${s3Url}) (必须下载附件,了解内容后才能回复)`);
}
}
} catch (e) { err(` ❌ 附件失败: ${e.message}`); }
}
}
if (atts.length) txt += (txt ? '\n\n' : '') + atts.join('\n');
const isCommand = txt.trim().startsWith('/');
if (txt || atts.length > 0) {
if (!txt && atts.length > 0) txt = "发送了附件。";
await new Promise(r => setTimeout(r, 500));
triggerAgent(cid, m.user_id, sender, txt, isCommand);
}
}
}
} catch (e) {
if(e.message!=="No messages") err(`[${state.name}] 跳过: ${e.message}`);
} finally { state.syncing = false; }
}
async function loop() {
const delay = Math.max(parseInt(POLL_INTERVAL || 5000), 2000);
if (isClawReady) {
for (const cid of channelRuntime.keys()) {
await syncChannel(cid);
await new Promise(r => setTimeout(r, 200));
}
}
setTimeout(loop, delay);
}
loop();