Download server1.js from Selinore/cc: direct link, hf CLI and curl.
- Browser
- Download file 47.5 kB
-
https://huggingface.co/spaces/Selinore/cc/resolve/main/server1.js
- Command line
-
hf download hf://spaces/Selinore/cc/server1.js
-
curl -L -o server1.js https://huggingface.co/spaces/Selinore/cc/resolve/main/server1.js
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,'<')}</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(); | |
| ; | |
| 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,'<')}</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(); |