Files
wecome-openclaw-client/electron/main.js
徐总 0880813355 feat: 添加消息历史持久化和可视化查看功能
- 新增 messageStore.js 消息存储模块,支持自动保存所有收发消息
- 修改 main.js,在消息转发时自动记录到本地存储
- 修改 preload.js,暴露消息管理 IPC API
- 修改 App.js,添加消息历史查看界面
  - 统计信息面板(总数/接收/发送/会话数)
  - 会话列表和消息详情
  - 搜索、过滤、分页功能
  - 导出 JSON 和清空历史
- 新增完整文档(MESSAGE_HISTORY.md 等)
- 新增测试脚本 test-message-history.js

版本:v1.0.1
2026-03-10 04:09:26 +08:00

769 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const https = require('https');
const http = require('http');
const Store = require('electron-store');
const { v4: uuidv4 } = require('uuid');
const { WSClient } = require('@wecom/aibot-node-sdk');
const WebSocket = require('ws');
const crypto = require('crypto');
const messageStore = require('./messageStore');
// 初始化配置存储
const store = new Store({
name: 'config',
defaults: {
bots: [],
openclaw: {
url: 'ws://localhost:18789',
token: '',
enabled: true
}
}
});
let mainWindow;
let wecomConnections = new Map();
let openclawConnection = null;
let reqIdMap = new Map();
// 下载文件到本地
async function downloadFile(url, aesKey = null) {
return new Promise((resolve, reject) => {
const chunks = [];
const req = (url.startsWith('https') ? https : http).get(url, (res) => {
res.on('data', chunk => chunks.push(chunk));
res.on('end', () => {
const buffer = Buffer.concat(chunks);
resolve({ buffer, filename: `file_${Date.now()}` });
});
});
req.on('error', reject);
req.setTimeout(30000, () => {
req.destroy();
reject(new Error('Download timeout'));
});
});
}
// 保存媒体文件
async function saveMediaFile(buffer, filename, mediaType = 'inbound') {
const mediaDir = path.join(app.getPath('userData'), 'media', mediaType);
fs.mkdirSync(mediaDir, { recursive: true });
const ext = path.extname(filename) || '.dat';
const savePath = path.join(mediaDir, `${filename}_${Date.now()}${ext}`);
fs.writeFileSync(savePath, buffer);
return { path: savePath, contentType: 'application/octet-stream' };
}
class WeComConnection {
constructor(botConfig, eventHandler) {
this.botId = botConfig.botId;
this.secret = botConfig.secret;
this.accountId = botConfig.id || botConfig.botId;
this.eventHandler = eventHandler;
this.client = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 100;
}
async connect() {
try {
console.log(`[WeCom] Connecting bot ${this.botId}...`);
this.client = new WSClient({
botId: this.botId,
secret: this.secret,
wsUrl: 'wss://openws.work.weixin.qq.com',
logger: {
debug: (msg) => console.log(`[WeCom:${this.botId}] DEBUG:`, msg),
info: (msg) => console.log(`[WeCom:${this.botId}] INFO:`, msg),
warn: (msg) => console.warn(`[WeCom:${this.botId}] WARN:`, msg),
error: (msg) => console.error(`[WeCom:${this.botId}] ERROR:`, msg)
},
heartbeatInterval: 30000,
maxReconnectAttempts: this.maxReconnectAttempts
});
this.client.on('connected', () => {
this.isConnected = true;
this.reconnectAttempts = 0;
console.log(`[WeCom:${this.botId}] Connected`);
wecomConnections.set(this.botId, this);
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('connected', { botId: this.botId });
}
});
this.client.on('disconnected', (reason) => {
this.isConnected = false;
console.log(`[WeCom:${this.botId}] Disconnected: ${reason}`);
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('disconnected', { botId: this.botId, reason });
}
this.scheduleReconnect();
});
this.client.on('error', (error) => {
console.error(`[WeCom:${this.botId}] Error:`, error);
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('error', { botId: this.botId, error: error.message });
}
if (error.message.includes('Authentication failed')) return;
});
this.client.on('message', async (frame) => {
console.log(`[WeCom:${this.botId}] Received message`);
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('message', { botId: this.botId, frame });
}
await this.forwardMessageToOpenClaw(frame);
});
await this.client.connect();
} catch (error) {
console.error(`[WeCom:${this.botId}] Connect error:`, error);
this.eventHandler('error', { botId: this.botId, error: error.message });
this.scheduleReconnect();
}
}
// 转发消息到 OpenClaw - 使用 chat.send API
async forwardMessageToOpenClaw(frame) {
if (!openclawConnection || !openclawConnection.isConnected) {
console.log('[WeCom] OpenClaw not connected, skipping forward');
return;
}
const body = frame.body;
const chatId = body.chatid || body.from.userid;
const chatType = body.chattype === 'group' ? 'group' : 'direct';
const messageId = body.msgid;
const reqId = frame.headers.req_id;
reqIdMap.set(chatId, reqId);
// 提取文本
let text = '';
if (body.text?.content) {
text = body.text.content;
} else if (body.mixed?.msg_item) {
for (const item of body.mixed.msg_item) {
if (item.msgtype === 'text' && item.text?.content) {
text += item.text.content + '\n';
}
}
} else if (body.voice?.content) {
text = body.voice.content;
}
if (body.chattype === 'group') {
text = text.replace(/@\S+/g, '').trim();
}
// 构建 sessionKey格式botId:chatId
const sessionKey = `${this.botId}:${chatId}`;
// 处理媒体文件
const attachments = [];
// 下载图片
if (body.image?.url) {
try {
console.log('[WeCom] Downloading image:', body.image.url);
const { buffer } = await downloadFile(body.image.url, body.image.aeskey);
const saved = await saveMediaFile(buffer, 'image', 'inbound');
attachments.push({
type: 'image',
path: saved.path,
contentType: saved.contentType
});
console.log('[WeCom] Image saved:', saved.path);
} catch (error) {
console.error('[WeCom] Failed to download image:', error.message);
}
}
// 下载文件
if (body.file?.url) {
try {
console.log('[WeCom] Downloading file:', body.file.url);
const { buffer } = await downloadFile(body.file.url, body.file.aeskey);
const saved = await saveMediaFile(buffer, 'file', 'inbound');
attachments.push({
type: 'file',
path: saved.path,
contentType: saved.contentType
});
console.log('[WeCom] File saved:', saved.path);
} catch (error) {
console.error('[WeCom] Failed to download file:', error.message);
}
}
// 使用 chat.send 方法发送消息到 OpenClaw
const chatSendMessage = {
type: 'req',
id: this.nextId(),
method: 'chat.send',
params: {
sessionKey: sessionKey,
message: text,
attachments: attachments.length > 0 ? attachments : undefined,
deliver: true, // 立即投递到 AI
idempotencyKey: messageId // 使用消息 ID 作为幂等键
}
};
console.log('[OpenClaw] Sending via chat.send:', sessionKey);
openclawConnection.send(JSON.stringify(chatSendMessage));
console.log(`[Forward] WeCom -> OpenClaw: ${messageId}`);
// 记录消息到存储
messageStore.saveMessage({
direction: 'inbound',
source: 'wecom',
sessionId: sessionKey,
chatId: chatId,
senderId: body.from?.userid || '',
senderName: body.from?.name || body.from?.userid || '',
messageType: body.image ? 'image' : body.file ? 'file' : body.voice ? 'voice' : 'text',
content: text,
attachments: attachments,
metadata: {
wecomMsgId: messageId,
chatType: chatType,
reqId: reqId
}
});
}
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error(`[WeCom:${this.botId}] Max reconnect attempts reached`);
return;
}
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(1.5, this.reconnectAttempts), 60000);
console.log(`[WeCom:${this.botId}] Reconnecting in ${delay}ms`);
setTimeout(() => this.connect(), delay);
}
async sendMessage(chatId, content, finish = true, streamId = null) {
if (!this.client || !this.isConnected) {
throw new Error('Not connected');
}
const actualStreamId = streamId || generateReqId('stream');
const reqId = reqIdMap.get(chatId) || generateReqId('req');
const response = {
cmd: 'aibot_respond_msg',
headers: { req_id: reqId },
body: {
msgtype: 'stream',
stream: {
id: actualStreamId,
finish: finish,
content: content
}
}
};
console.log(`[WeCom:${this.botId}] Sending to ${chatId}:`, content.substring(0, 100));
await this.client.send(response);
return actualStreamId;
}
disconnect() {
if (this.client) {
this.client.close();
this.isConnected = false;
wecomConnections.delete(this.botId);
}
}
}
class OpenClawConnection {
constructor(url, token, eventHandler) {
this.url = url?.trim().replace(/\/+$/, '') || 'ws://localhost:18789';
this.token = token;
this.eventHandler = eventHandler;
this.socket = null;
this.isConnected = false;
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 100;
this.messageId = 0;
this.deviceId = `wecome-client_${process.platform}_${uuidv4()}`;
}
async connect() {
try {
console.log('='.repeat(60));
console.log('[OpenClaw] ========== 开始连接 ==========');
console.log('[OpenClaw] 目标地址:', this.url);
const isSecure = this.url.startsWith('wss://');
console.log(`[OpenClaw] 连接类型:${isSecure ? 'WSS (SSL)' : 'WS (非加密)'}`);
const wsOptions = {
rejectUnauthorized: false,
followRedirects: true,
handshakeTimeout: 10000
};
if (isSecure) {
wsOptions.tls = {
rejectUnauthorized: false,
minVersion: 'TLSv1.2'
};
console.log('[OpenClaw] SSL 配置:已启用(允许自签名证书)');
}
console.log('[OpenClaw] 创建 WebSocket 连接...');
this.socket = new WebSocket(this.url, wsOptions);
this.socket.on('open', () => {
console.log('[OpenClaw] ✅ WebSocket 连接已建立');
console.log('[OpenClaw] 就绪状态:', this.socket.readyState, '(1=OPEN)');
this.isConnected = true;
this.reconnectAttempts = 0;
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('connected');
}
console.log('[OpenClaw] 📤 发送 connect 请求...');
this.sendConnect();
});
this.socket.on('message', (data) => {
if (!mainWindow || mainWindow.isDestroyed()) return;
const messageStr = data.toString();
console.log('[OpenClaw] 📥 收到消息 (长度:', messageStr.length, '字节)');
console.log('[OpenClaw] 原始数据:', messageStr.substring(0, 500));
const message = JSON.parse(messageStr);
console.log('[OpenClaw] 消息类型:', message.type);
if (message.type === 'event') {
console.log('[OpenClaw] 事件名称:', message.event);
} else if (message.type === 'res') {
console.log('[OpenClaw] 响应 ID:', message.id);
console.log('[OpenClaw] 响应状态:', message.ok ? '✅ 成功' : '❌ 失败');
if (!message.ok) {
console.log('[OpenClaw] 错误信息:', JSON.stringify(message.error));
}
}
if (message.type === 'event' && message.event === 'connect.challenge') {
console.log('[OpenClaw] 🔐 收到 challenge 质询');
console.log('[OpenClaw] Nonce:', message.payload?.nonce);
this.sendConnect(message.payload?.nonce);
return;
}
this.handleMessage(message);
});
this.socket.on('close', (event) => {
console.log('[OpenClaw] 🔴 连接已关闭');
console.log('[OpenClaw] 关闭代码:', event.code);
console.log('[OpenClaw] 关闭原因:', event.reason || '无');
console.log('[OpenClaw] 是否干净:', event.wasClean);
this.isConnected = false;
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('disconnected');
}
if (event.code === 1006) {
console.log('[OpenClaw] ⚠️ 异常关闭1006- 网络问题或服务器拒绝');
}
this.scheduleReconnect();
});
this.socket.on('error', (error) => {
console.error('[OpenClaw] ❌ WebSocket 错误:', error.message);
if (error.message?.includes('WRONG_VERSION_NUMBER')) {
console.error('[OpenClaw] 🔴 SSL/TLS 协议不匹配!');
console.error('[OpenClaw] 建议:尝试改用 ws://(非加密)');
}
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('error', { error: error.message });
}
});
} catch (error) {
console.error('[OpenClaw] ❌ 连接异常:', error.message);
this.eventHandler('error', { error: error.message });
this.scheduleReconnect();
}
}
sendConnect(nonce = null) {
const tempPublicKey = crypto.randomBytes(32).toString('hex');
const tempSignature = crypto.randomBytes(64).toString('hex');
const connectMessage = {
type: 'req',
id: this.nextId(),
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
client: {
id: 'cli',
version: '1.0.0',
platform: process.platform,
mode: 'operator'
},
role: 'operator',
scopes: ['operator.read', 'operator.write'],
caps: [],
commands: [],
permissions: {},
auth: this.token ? { token: this.token } : {},
locale: 'zh-CN',
userAgent: 'wecome-openclaw-client/1.0.0',
device: {
id: this.deviceId,
publicKey: tempPublicKey,
signature: tempSignature,
signedAt: Date.now(),
nonce: nonce || crypto.randomUUID()
}
}
};
console.log('[OpenClaw] 发送 connect 请求');
this.send(connectMessage);
}
handleMessage(message) {
if (message.type === 'res' && message.ok) {
if (message.payload?.type === 'hello-ok') {
console.log('[OpenClaw] ✅ Handshake 成功!协议版本:', message.payload.protocol);
console.log('[OpenClaw] Policy:', JSON.stringify(message.payload.policy));
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.webContents.send('openclaw-event', {
eventType: 'handshake-ok',
data: { protocol: message.payload.protocol }
});
}
}
} else if (message.type === 'res' && !message.ok) {
console.error('[OpenClaw] ❌ Connect 失败:', JSON.stringify(message.error));
} else if (message.type === 'event') {
if (message.event === 'message.outbound') {
this.forwardMessageToWeCom(message);
}
}
}
async forwardMessageToWeCom(message) {
const payload = message.payload;
const { channel, text, chatId, botId, streamId, finish } = payload;
if (channel !== 'wecom') return;
const wecomConn = wecomConnections.get(botId);
if (!wecomConn) {
console.error(`[OpenClaw] Bot ${botId} not connected`);
return;
}
try {
await wecomConn.sendMessage(chatId, text, finish, streamId);
console.log(`[Forward] OpenClaw -> WeCom: ${chatId}`);
// 记录消息到存储
const sessionKey = `${botId}:${chatId}`;
messageStore.saveMessage({
direction: 'outbound',
source: 'openclaw',
sessionId: sessionKey,
chatId: chatId,
senderId: 'openclaw',
senderName: 'OpenClaw AI',
messageType: 'text',
content: text,
metadata: {
streamId: streamId,
finish: finish,
botId: botId
}
});
} catch (error) {
console.error('[Forward] Error:', error);
}
}
send(message) {
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
this.socket.send(JSON.stringify(message));
}
}
nextId() {
return `msg_${++this.messageId}_${Date.now()}`;
}
scheduleReconnect() {
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
console.error('[OpenClaw] Max reconnect attempts reached');
return;
}
this.reconnectAttempts++;
const delay = Math.min(1000 * Math.pow(1.5, this.reconnectAttempts), 60000);
console.log(`[OpenClaw] Reconnecting in ${delay}ms`);
setTimeout(() => this.connect(), delay);
}
disconnect() {
if (this.socket) {
this.socket.close();
this.isConnected = false;
}
}
}
function generateReqId(prefix = 'msg') {
return `${prefix}_${uuidv4().substring(0, 8)}_${Date.now()}`;
}
function createWindow() {
mainWindow = new BrowserWindow({
width: 1200,
height: 800,
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
},
icon: path.join(__dirname, '../resources/icon.png')
});
const isDev = process.env.NODE_ENV === 'development';
if (isDev) {
mainWindow.loadURL('http://localhost:3000');
mainWindow.webContents.openDevTools();
} else {
mainWindow.loadFile(path.join(__dirname, '../renderer/build/index.html'));
}
mainWindow.on('closed', () => {
mainWindow = null;
});
}
function createTray() {
const iconPath = path.join(__dirname, '../resources/icon.png');
const trayIcon = nativeImage.createFromPath(iconPath).resize({ width: 16, height: 16 });
const tray = new Tray(trayIcon);
const contextMenu = Menu.buildFromTemplate([
{ label: '显示主窗口', click: () => mainWindow.show() },
{ label: '退出', click: () => app.quit() }
]);
tray.setToolTip('WeCom OpenClaw Client');
tray.setContextMenu(contextMenu);
return tray;
}
function setupIpcHandlers() {
ipcMain.handle('get-config', () => store.store);
ipcMain.handle('save-config', (event, config) => {
store.set(config);
return { success: true };
});
ipcMain.handle('connect-wecom', (event, botConfig) => {
const { botId, secret, id } = botConfig;
if (wecomConnections.has(botId)) {
wecomConnections.get(botId).disconnect();
}
const connection = new WeComConnection({ botId, secret, id }, (eventType, data) => {
mainWindow.webContents.send('wecom-event', { eventType, data });
});
connection.connect();
return { success: true };
});
ipcMain.handle('disconnect-wecom', (event, botId) => {
const conn = wecomConnections.get(botId);
if (conn) {
conn.disconnect();
}
return { success: true };
});
ipcMain.handle('connect-openclaw', (event, config) => {
const { url, token } = config;
if (openclawConnection) {
openclawConnection.disconnect();
}
openclawConnection = new OpenClawConnection(url, token, (eventType, data) => {
mainWindow.webContents.send('openclaw-event', { eventType, data });
});
openclawConnection.connect();
return { success: true };
});
ipcMain.handle('disconnect-openclaw', () => {
if (openclawConnection) {
openclawConnection.disconnect();
openclawConnection = null;
}
return { success: true };
});
ipcMain.handle('get-connection-status', () => {
const wecomStatus = {};
wecomConnections.forEach((conn, botId) => {
wecomStatus[botId] = {
connected: conn.isConnected,
reconnectAttempts: conn.reconnectAttempts
};
});
return {
wecom: wecomStatus,
openclaw: openclawConnection ? { connected: openclawConnection.isConnected } : { connected: false }
};
});
ipcMain.handle('send-test-message', async (event, botId, chatId, text) => {
const conn = wecomConnections.get(botId);
if (!conn) return { success: false, error: 'Bot not connected' };
try {
await conn.sendMessage(chatId, text, true);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
ipcMain.handle('send-test-openclaw-message', async (event, text) => {
if (!openclawConnection || !openclawConnection.isConnected) {
return { success: false, message: 'OpenClaw 未连接' };
}
try {
const testMessage = {
type: 'req',
id: openclawConnection.nextId(),
method: 'chat.send',
params: {
text: text,
sessionKey: `test_${Date.now()}`,
deliver: true,
idempotencyKey: uuidv4()
}
};
console.log('[OpenClaw] Sending test message:', testMessage);
openclawConnection.send(testMessage);
return {
success: true,
message: `测试消息已发送:"${text}"`,
timestamp: new Date().toLocaleTimeString()
};
} catch (error) {
return { success: false, message: `发送失败:${error.message}` };
}
});
// ============ 消息存储相关 IPC 处理器 ============
// 获取消息列表
ipcMain.handle('get-messages', (event, options = {}) => {
return messageStore.getMessages(options);
});
// 获取会话列表
ipcMain.handle('get-sessions', () => {
return messageStore.getSessions();
});
// 搜索消息
ipcMain.handle('search-messages', (event, query, options = {}) => {
return messageStore.searchMessages(query, options);
});
// 获取统计数据
ipcMain.handle('get-message-stats', () => {
return messageStore.getStats();
});
// 标记消息为已读
ipcMain.handle('mark-messages-read', (event, messageIds) => {
return messageStore.markAsRead(messageIds);
});
// 导出消息
ipcMain.handle('export-messages', (event, options = {}) => {
return messageStore.exportMessages(options);
});
// 清空消息
ipcMain.handle('clear-messages', (event, options = {}) => {
messageStore.clear(options);
return { success: true };
});
}
app.whenReady().then(() => {
createWindow();
createTray();
setupIpcHandlers();
setTimeout(async () => {
const config = store.store;
if (config.openclaw.enabled) {
await ipcMain.emit('connect-openclaw', null, config.openclaw);
}
for (const bot of config.bots) {
if (bot.enabled) {
await ipcMain.emit('connect-wecom', null, bot);
}
}
}, 1000);
app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) {
createWindow();
}
});
});
app.on('window-all-closed', () => {
wecomConnections.forEach(conn => conn.disconnect());
if (openclawConnection) {
openclawConnection.disconnect();
}
if (process.platform !== 'darwin') {
app.quit();
}
});
app.on('quit', () => {
console.log('[App] Quitting, cleaning up connections...');
wecomConnections.forEach(conn => conn.disconnect());
if (openclawConnection) {
openclawConnection.disconnect();
}
});
app.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors');