Update server.js
Browse files
server.js
CHANGED
|
@@ -546,10 +546,10 @@ const {
|
|
| 546 |
} = require('@aws-sdk/client-s3');
|
| 547 |
|
| 548 |
// ============================================================
|
| 549 |
-
// 0
|
| 550 |
// ============================================================
|
| 551 |
|
| 552 |
-
const PORT =
|
| 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
|
| 572 |
|
| 573 |
-
|
| 574 |
-
|
| 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 >
|
| 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 |
-
|
| 607 |
-
|
| 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:
|
| 619 |
});
|
| 620 |
|
| 621 |
const s3 = new S3Client({
|
| 622 |
region: STORAGE_REGION,
|
| 623 |
endpoint: STORAGE_EXTERNAL_ENDPOINT,
|
| 624 |
-
forcePathStyle:
|
| 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 |
-
|
| 636 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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('
|
| 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 ``;
|
| 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
|
| 774 |
Bucket: STORAGE_PUBLIC_BUCKET,
|
| 775 |
Key: S3_CONFIG_KEY
|
| 776 |
}));
|
| 777 |
-
const
|
| 778 |
-
const
|
|
|
|
| 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 |
-
|
| 796 |
-
} else {
|
| 797 |
-
err(`[S3] 读取配置失败: ${e.message}`);
|
| 798 |
-
}
|
| 799 |
}
|
| 800 |
}
|
| 801 |
|
| 802 |
async function saveChannelsToS3() {
|
| 803 |
try {
|
| 804 |
-
const
|
| 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] 保存
|
| 819 |
}
|
| 820 |
}
|
| 821 |
|
| 822 |
-
async function uploadToS3(filename, buffer,
|
| 823 |
const key = `${S3_UPLOAD_PREFIX}${filename}`;
|
| 824 |
try {
|
| 825 |
-
log(`☁️ [Debug]
|
| 826 |
await s3.send(new PutObjectCommand({
|
| 827 |
Bucket: STORAGE_PUBLIC_BUCKET,
|
| 828 |
Key: key,
|
| 829 |
Body: buffer,
|
| 830 |
-
ContentType:
|
| 831 |
-
|
| 832 |
}));
|
| 833 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 845 |
-
|
| 846 |
-
let
|
| 847 |
-
let removed = 0;
|
| 848 |
|
| 849 |
do {
|
| 850 |
-
const
|
| 851 |
Bucket: STORAGE_PUBLIC_BUCKET,
|
| 852 |
Prefix: S3_UPLOAD_PREFIX,
|
| 853 |
ContinuationToken: token
|
| 854 |
}));
|
| 855 |
-
const list = res.Contents || [];
|
| 856 |
|
| 857 |
-
for (const
|
| 858 |
-
if (
|
| 859 |
await s3.send(new DeleteObjectCommand({
|
| 860 |
Bucket: STORAGE_PUBLIC_BUCKET,
|
| 861 |
-
Key:
|
| 862 |
}));
|
| 863 |
-
|
| 864 |
}
|
| 865 |
}
|
| 866 |
-
token =
|
| 867 |
} while (token);
|
| 868 |
|
| 869 |
-
log(`🧹 [S3] 清理
|
| 870 |
} catch (e) {
|
| 871 |
err(`[S3] 清理失败: ${e.message}`);
|
| 872 |
}
|
| 873 |
}
|
| 874 |
|
| 875 |
// ============================================================
|
| 876 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 902 |
cleanupConnection();
|
| 903 |
isReconnecting = false;
|
| 904 |
-
const delay = Math.min(5000 * Math.pow(1.5,
|
| 905 |
if (reconnectTimer) clearTimeout(reconnectTimer);
|
| 906 |
-
reconnectTimer = setTimeout(() => connectOpenClaw(
|
| 907 |
}
|
| 908 |
|
| 909 |
-
function connectOpenClaw(
|
| 910 |
if (isReconnecting || ws) return;
|
| 911 |
isReconnecting = true;
|
| 912 |
-
log(`🔌 连接 OpenClaw (${
|
| 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 |
-
|
| 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',
|
| 936 |
ws.on('close', (code) => {
|
| 937 |
-
log(`🔌 WS断开
|
| 938 |
-
handleDisconnect(
|
| 939 |
});
|
| 940 |
ws.on('error', (e) => err(`WS错误: ${e.message}`));
|
| 941 |
} catch (e) {
|
| 942 |
err(`WS初始化失败: ${e.message}`);
|
| 943 |
-
handleDisconnect(
|
| 944 |
}
|
| 945 |
}
|
| 946 |
|
| 947 |
-
function
|
| 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
|
| 1006 |
pendingRequests.delete(msg.id);
|
| 1007 |
-
try {
|
| 1008 |
}
|
| 1009 |
}
|
| 1010 |
|
| 1011 |
-
function
|
| 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
|
| 1047 |
-
|
| 1048 |
-
|
| 1049 |
type: 'req',
|
| 1050 |
id: crypto.randomUUID(),
|
| 1051 |
-
method
|
| 1052 |
params: {
|
| 1053 |
-
sessionKey: `agent:main:${cid}`,
|
| 1054 |
-
message:
|
| 1055 |
idempotencyKey: crypto.randomUUID(),
|
| 1056 |
deliver: isCommand ? false : undefined
|
| 1057 |
}
|
| 1058 |
-
}
|
| 1059 |
-
|
| 1060 |
-
log(`🚀 [发送
|
| 1061 |
}
|
| 1062 |
|
| 1063 |
// ============================================================
|
| 1064 |
-
//
|
| 1065 |
// ============================================================
|
| 1066 |
|
| 1067 |
async function initChannelBaseline(cid) {
|
| 1068 |
-
const
|
| 1069 |
-
if (!
|
|
|
|
| 1070 |
try {
|
| 1071 |
-
const { data } = await webuiApi.get(`/api/v1/channels/${cid}/messages?limit=1`);
|
| 1072 |
-
|
| 1073 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1074 |
} catch (e) {
|
| 1075 |
-
err(`[${
|
| 1076 |
setTimeout(() => initChannelBaseline(cid), 5000);
|
| 1077 |
}
|
| 1078 |
}
|
| 1079 |
|
| 1080 |
-
|
| 1081 |
-
//
|
| 1082 |
-
// ===
|
|
|
|
|
|
|
| 1083 |
|
| 1084 |
-
|
| 1085 |
-
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1091 |
} catch (e) {
|
| 1092 |
-
err(`
|
| 1093 |
-
return {};
|
| 1094 |
}
|
|
|
|
|
|
|
| 1095 |
}
|
| 1096 |
|
| 1097 |
-
|
| 1098 |
-
if (msg?.data && typeof msg.data === 'object') return msg.data;
|
| 1099 |
-
|
| 1100 |
-
return {};
|
| 1101 |
}
|
| 1102 |
|
| 1103 |
-
async function
|
| 1104 |
-
const
|
| 1105 |
-
|
| 1106 |
-
|
| 1107 |
-
if (!files.length) return [];
|
| 1108 |
|
| 1109 |
-
log(`🔎 [Debug]
|
| 1110 |
|
| 1111 |
-
const
|
| 1112 |
-
for (const fItem of files) {
|
| 1113 |
try {
|
| 1114 |
-
const f = fItem?.file || fItem || {};
|
| 1115 |
let buffer = null;
|
| 1116 |
-
let
|
| 1117 |
-
let
|
| 1118 |
-
|
| 1119 |
-
//
|
| 1120 |
-
if (typeof
|
| 1121 |
-
|
| 1122 |
-
|
| 1123 |
-
|
| 1124 |
-
|
| 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 |
-
//
|
| 1132 |
-
if (!buffer
|
| 1133 |
-
const
|
| 1134 |
-
|
| 1135 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
| 1147 |
if (!url) continue;
|
| 1148 |
|
| 1149 |
-
const isImage =
|
| 1150 |
-
atts.push(`[${isImage ? '图片' : '文件'}: ${
|
| 1151 |
-
log(`✅
|
| 1152 |
} catch (e) {
|
| 1153 |
-
err(`附件处理
|
| 1154 |
}
|
| 1155 |
}
|
| 1156 |
|
| 1157 |
return atts;
|
| 1158 |
}
|
| 1159 |
|
| 1160 |
-
|
| 1161 |
-
|
| 1162 |
-
|
| 1163 |
|
| 1164 |
-
|
| 1165 |
-
|
| 1166 |
-
|
| 1167 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1168 |
} else {
|
| 1169 |
return;
|
| 1170 |
}
|
| 1171 |
}
|
| 1172 |
|
| 1173 |
-
|
| 1174 |
-
|
| 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) ||
|
| 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 |
-
|
| 1194 |
-
|
| 1195 |
-
|
| 1196 |
-
|
| 1197 |
-
|
| 1198 |
-
|
| 1199 |
-
|
| 1200 |
-
|
| 1201 |
-
|
| 1202 |
-
|
| 1203 |
-
|
| 1204 |
-
|
| 1205 |
-
|
| 1206 |
-
|
| 1207 |
-
|
| 1208 |
-
|
| 1209 |
-
|
|
|
|
| 1210 |
}
|
| 1211 |
}
|
| 1212 |
|
| 1213 |
-
|
| 1214 |
-
|
| 1215 |
-
|
| 1216 |
-
|
|
|
|
| 1217 |
|
| 1218 |
-
|
| 1219 |
-
|
|
|
|
|
|
|
| 1220 |
|
| 1221 |
-
|
| 1222 |
-
|
| 1223 |
-
|
|
|
|
| 1224 |
|
| 1225 |
-
|
| 1226 |
-
|
| 1227 |
-
.replace(new RegExp(`@${BOT_USERNAME}`, 'gi'), '')
|
| 1228 |
-
.trim();
|
| 1229 |
|
| 1230 |
-
|
| 1231 |
-
|
| 1232 |
-
|
| 1233 |
|
| 1234 |
-
|
| 1235 |
-
|
| 1236 |
|
| 1237 |
-
|
| 1238 |
-
|
| 1239 |
-
|
| 1240 |
-
}
|
| 1241 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1242 |
} catch (e) {
|
| 1243 |
-
|
| 1244 |
} finally {
|
| 1245 |
-
|
| 1246 |
}
|
| 1247 |
}
|
| 1248 |
|
| 1249 |
-
async function
|
| 1250 |
-
const
|
| 1251 |
-
|
| 1252 |
if (isClawReady) {
|
| 1253 |
-
const
|
| 1254 |
-
for (const cid of ids) {
|
| 1255 |
await syncChannel(cid);
|
| 1256 |
-
await
|
| 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,'<')).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 |
-
//
|
| 1396 |
// ============================================================
|
| 1397 |
|
| 1398 |
async function init() {
|
| 1399 |
-
log(`🚀
|
| 1400 |
-
log(`
|
| 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(
|
| 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,'<')+'</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 |
+
});
|
|
|
|
|
|