Files
wecome-openclaw-client/electron/main.js
2026-03-10 00:57:04 +08:00

719 lines
21 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 Store = require('electron-store');
const { v4: uuidv4 } = require('uuid');
const { WSClient } = require('@wecom/aibot-node-sdk');
const WebSocket = require('ws');
// 初始化配置存储
const store = new Store({
name: 'config',
defaults: {
bots: [], // 多 Bot 配置 [{id, botId, secret, name, enabled}]
openclaw: {
url: 'ws://localhost:18789',
token: '',
enabled: true
}
}
});
let mainWindow;
let wecomConnections = new Map(); // botId -> WeComConnection 实例
let openclawConnection = null; // OpenClawConnection 实例
let messageState = new Map(); // msgId -> { streamId, accumulatedText, frame, botId }
let reqIdMap = new Map(); // chatId -> reqId (用于回复消息)
// 创建主窗口
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;
}
// 生成唯一的请求 ID
function generateReqId(prefix = 'msg') {
return `${prefix}_${uuidv4().substring(0, 8)}_${Date.now()}`;
}
// 企业微信 WebSocket 连接管理
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, ...args) => console.log(`[WeCom:${this.botId}] DEBUG:`, msg, ...args),
info: (msg, ...args) => console.log(`[WeCom:${this.botId}] INFO:`, msg, ...args),
warn: (msg, ...args) => console.warn(`[WeCom:${this.botId}] WARN:`, msg, ...args),
error: (msg, ...args) => console.error(`[WeCom:${this.botId}] ERROR:`, msg, ...args)
},
heartbeatInterval: 30000, // 30 秒心跳
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('authenticated', () => {
console.log(`[WeCom:${this.botId}] Authenticated`);
});
// 监听断开
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;
}
});
// 监听消息 - 核心:接收企业微信消息并转发到 OpenClaw
this.client.on('message', async (frame) => {
console.log(`[WeCom:${this.botId}] Received message:`, JSON.stringify(frame, null, 2));
// 检查窗口是否存在
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('message', { botId: this.botId, frame });
}
// 将消息转发到 OpenClaw
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
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;
// 保存 reqId 用于后续回复
reqIdMap.set(chatId, reqId);
// 构建转发到 OpenClaw 的消息
// 参考 /home/wecom 插件的消息处理逻辑
const text = this.extractTextFromFrame(frame);
const hasMedia = this.hasMediaInFrame(frame);
// 构建 OpenClaw 消息格式
const openclawMessage = {
type: 'event',
event: 'message.inbound',
payload: {
channel: 'wecom',
accountId: this.accountId,
message: {
id: messageId,
from: body.from.userid,
chatId: chatId,
chatType: chatType,
text: text,
hasMedia: hasMedia,
timestamp: body.create_time || Date.now()
},
reqId: reqId,
botId: this.botId
}
};
// 发送到 OpenClaw
openclawConnection.send(JSON.stringify(openclawMessage));
console.log(`[Forward] WeCom -> OpenClaw: ${messageId}`);
}
// 从 frame 中提取文本
extractTextFromFrame(frame) {
const body = frame.body;
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();
}
return text.trim();
}
// 检查是否有媒体
hasMediaInFrame(frame) {
const body = frame.body;
return !!(body.image?.url || body.file?.url ||
(body.mixed?.msg_item?.some(item => item.msgtype === 'image')));
}
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 (attempt ${this.reconnectAttempts})`);
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 message 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);
}
}
}
// OpenClaw Gateway WebSocket 连接
class OpenClawConnection {
constructor(url, token, eventHandler) {
// 清理 URL - 移除末尾斜杠
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.protocolVersion = 3;
this.deviceId = `wecome-client_${process.platform}_${uuidv4()}`;
}
async connect() {
try {
console.log('[OpenClaw] Connecting to', this.url);
const isSecure = this.url.startsWith('wss://');
console.log(`[OpenClaw] Using ${isSecure ? 'WSS (SSL)' : 'WS (non-SSL)'} connection`);
// 创建 WebSocket 配置
const wsOptions = {
rejectUnauthorized: false, // 允许自签名证书
followRedirects: true
};
// 如果是 wss://,添加更多 SSL 选项
if (isSecure) {
wsOptions.tls = {
rejectUnauthorized: false,
minVersion: 'TLSv1.2'
};
}
this.socket = new WebSocket(this.url, wsOptions);
this.socket.on('open', () => {
this.isConnected = true;
this.reconnectAttempts = 0;
console.log('[OpenClaw] WebSocket connected, waiting for challenge...');
// 检查窗口是否存在
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('connected');
}
// 注意:不立即发送 connect等待服务器发送 connect.challenge 后再响应
});
this.socket.on('message', (data) => {
// 窗口已关闭时忽略消息
if (!mainWindow || mainWindow.isDestroyed()) {
return;
}
try {
const message = JSON.parse(data.toString());
console.log('[OpenClaw] Received:', JSON.stringify(message, null, 2));
// 处理 connect.challenge 质询
if (message.type === 'event' && message.event === 'connect.challenge') {
console.log('[OpenClaw] Received challenge, sending connect request...');
this.sendConnect(message.payload?.nonce);
return;
}
this.handleMessage(message);
} catch (error) {
console.error('[OpenClaw] Parse error:', error);
}
});
this.socket.on('close', () => {
this.isConnected = false;
console.log('[OpenClaw] Disconnected');
// 检查窗口是否存在
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('disconnected');
}
this.scheduleReconnect();
});
this.socket.on('error', (error) => {
console.error('[OpenClaw] Error:', error.message);
// 检测 SSL/TLS 错误并给出建议
if (error.message?.includes('WRONG_VERSION_NUMBER')) {
console.error('[OpenClaw] ❗ SSL/TLS 协议不匹配!');
console.error('[OpenClaw] 建议:');
console.error('[OpenClaw] - 如果使用 wss://,请确认服务器支持 SSL');
console.error('[OpenClaw] - 如果服务器不支持 SSL请改用 ws://');
console.error('[OpenClaw] - 检查端口是否正确SSL 和非 SSL 可能使用不同端口)');
}
// 检查窗口是否存在
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('error', {
error: error.message,
hint: error.message?.includes('WRONG_VERSION_NUMBER') ?
'SSL 协议不匹配,请检查是否应该使用 ws:// 而不是 wss://' : null
});
}
});
} catch (error) {
this.eventHandler('error', { error: error.message });
this.scheduleReconnect();
}
}
// 发送 connect 握手(响应 challenge
sendConnect(nonce = null) {
const connectMessage = {
type: 'req',
id: this.nextId(),
method: 'connect',
params: {
minProtocol: this.protocolVersion,
maxProtocol: this.protocolVersion,
client: {
id: 'wecome-client',
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: '',
signature: '',
signedAt: Date.now(),
nonce: nonce || uuidv4() // 使用服务器的 nonce 如果存在
}
}
};
console.log('[OpenClaw] Sending connect request:', JSON.stringify(connectMessage, null, 2));
this.send(connectMessage);
}
// 处理来自 OpenClaw 的消息(回复到企业微信)
handleMessage(message) {
if (message.type === 'res' && message.ok) {
// connect 响应
if (message.payload?.type === 'hello-ok') {
console.log('[OpenClaw] ✅ Handshake successful! Protocol version:', 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 failed:', message.error);
} else if (message.type === 'event') {
// 处理 OpenClaw 的事件
if (message.event === 'message.outbound') {
// OpenClaw 回复消息,需要转发到企业微信
this.forwardMessageToWeCom(message);
}
}
}
// 转发 OpenClaw 回复到企业微信
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}`);
} 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 (attempt ${this.reconnectAttempts})`);
setTimeout(() => this.connect(), delay);
}
disconnect() {
if (this.socket) {
this.socket.close();
this.isConnected = false;
}
}
}
// IPC 处理器
function setupIpcHandlers() {
// 获取配置
ipcMain.handle('get-config', () => {
return store.store;
});
// 保存配置
ipcMain.handle('save-config', (event, config) => {
store.set(config);
return { success: true };
});
// 连接企业微信 Bot
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 };
});
// 断开企业微信 Bot
ipcMain.handle('disconnect-wecom', (event, botId) => {
const conn = wecomConnections.get(botId);
if (conn) {
conn.disconnect();
wecomConnections.delete(botId);
}
return { success: true };
});
// 连接 OpenClaw
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 };
});
// 断开 OpenClaw
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 };
}
});
// 测试 OpenClaw 消息
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()}`,
echo: true // 要求回显
}
};
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}` };
}
});
}
// 应用生命周期
app.whenReady().then(() => {
createWindow();
createTray();
setupIpcHandlers();
// 自动加载配置并连接
setTimeout(async () => {
const config = store.store;
// 连接 OpenClaw
if (config.openclaw.enabled) {
await ipcMain.emit('connect-openclaw', null, config.openclaw);
}
// 连接所有启用的 Bot
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');