feat: 初始版本 - 企业微信 OpenClaw 图形配置客户端
- 基于 Electron + React 的跨平台桌面应用 - 支持多 Bot ID 和 Secret 配置 - 双 WebSocket 长连接(企业微信 + OpenClaw Gateway) - 图形化配置界面,实时连接状态显示 - 自动重连机制 - 支持 Windows/macOS/Linux 打包 技术栈: - Electron 28 - React 18 - @wecom/aibot-node-sdk - electron-store 配置持久化
This commit is contained in:
378
electron/main.js
Normal file
378
electron/main.js
Normal file
@@ -0,0 +1,378 @@
|
||||
const { app, BrowserWindow, ipcMain, dialog } = 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 配置 [{botId, secret, name, enabled}]
|
||||
openclaw: {
|
||||
url: 'ws://localhost:18789',
|
||||
token: '',
|
||||
enabled: true
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let mainWindow;
|
||||
let wecomClients = new Map(); // botId -> WSClient
|
||||
let openclawSocket = null;
|
||||
let heartbeatInterval = null;
|
||||
|
||||
// 创建主窗口
|
||||
function createWindow() {
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1200,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
nodeIntegration: false,
|
||||
contextIsolation: true,
|
||||
preload: path.join(__dirname, 'preload.js')
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
});
|
||||
}
|
||||
|
||||
// 企业微信 WebSocket 连接管理
|
||||
class WeComConnection {
|
||||
constructor(botId, secret, eventHandler) {
|
||||
this.botId = botId;
|
||||
this.secret = secret;
|
||||
this.eventHandler = eventHandler;
|
||||
this.client = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 100;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
try {
|
||||
this.client = new WSClient({
|
||||
botId: this.botId,
|
||||
secret: this.secret,
|
||||
onConnected: () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.eventHandler('connected', { botId: this.botId });
|
||||
console.log(`[WeCom] Bot ${this.botId} connected`);
|
||||
},
|
||||
onDisconnected: () => {
|
||||
this.isConnected = false;
|
||||
this.eventHandler('disconnected', { botId: this.botId });
|
||||
console.log(`[WeCom] Bot ${this.botId} disconnected`);
|
||||
this.scheduleReconnect();
|
||||
},
|
||||
onMessage: (frame) => {
|
||||
this.eventHandler('message', { botId: this.botId, frame });
|
||||
},
|
||||
onError: (error) => {
|
||||
this.eventHandler('error', { botId: this.botId, error: error.message });
|
||||
console.error(`[WeCom] Bot ${this.botId} error:`, error);
|
||||
}
|
||||
});
|
||||
|
||||
await this.client.connect();
|
||||
} catch (error) {
|
||||
this.eventHandler('error', { botId: this.botId, error: error.message });
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
scheduleReconnect() {
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(`[WeCom] Bot ${this.botId} max reconnect attempts reached`);
|
||||
return;
|
||||
}
|
||||
|
||||
this.reconnectAttempts++;
|
||||
const delay = Math.min(1000 * Math.pow(1.5, this.reconnectAttempts), 60000);
|
||||
console.log(`[WeCom] Bot ${this.botId} reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
|
||||
|
||||
setTimeout(() => this.connect(), delay);
|
||||
}
|
||||
|
||||
async sendCommand(cmd, body) {
|
||||
if (!this.client || !this.isConnected) {
|
||||
throw new Error('Not connected');
|
||||
}
|
||||
return await this.client.send({ cmd, headers: { req_id: uuidv4() }, body });
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
if (this.client) {
|
||||
this.client.close();
|
||||
this.isConnected = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// OpenClaw Gateway WebSocket 连接
|
||||
class OpenClawConnection {
|
||||
constructor(url, token, eventHandler) {
|
||||
this.url = url;
|
||||
this.token = token;
|
||||
this.eventHandler = eventHandler;
|
||||
this.socket = null;
|
||||
this.isConnected = false;
|
||||
this.reconnectAttempts = 0;
|
||||
this.maxReconnectAttempts = 100;
|
||||
this.messageId = 0;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
try {
|
||||
this.socket = new WebSocket(this.url);
|
||||
|
||||
this.socket.on('open', () => {
|
||||
this.isConnected = true;
|
||||
this.reconnectAttempts = 0;
|
||||
this.eventHandler('connected');
|
||||
console.log('[OpenClaw] Connected');
|
||||
|
||||
// 发送 connect 请求
|
||||
this.sendConnect();
|
||||
});
|
||||
|
||||
this.socket.on('message', (data) => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
this.eventHandler('message', message);
|
||||
} catch (error) {
|
||||
console.error('[OpenClaw] Parse error:', error);
|
||||
}
|
||||
});
|
||||
|
||||
this.socket.on('close', () => {
|
||||
this.isConnected = false;
|
||||
this.eventHandler('disconnected');
|
||||
console.log('[OpenClaw] Disconnected');
|
||||
this.scheduleReconnect();
|
||||
});
|
||||
|
||||
this.socket.on('error', (error) => {
|
||||
this.eventHandler('error', { error: error.message });
|
||||
console.error('[OpenClaw] Error:', error);
|
||||
});
|
||||
|
||||
} catch (error) {
|
||||
this.eventHandler('error', { error: error.message });
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
|
||||
sendConnect() {
|
||||
const connectMessage = {
|
||||
type: 'req',
|
||||
id: this.nextId(),
|
||||
method: 'connect',
|
||||
params: {
|
||||
minProtocol: 3,
|
||||
maxProtocol: 3,
|
||||
client: {
|
||||
id: 'wecome-client',
|
||||
version: '1.0.0',
|
||||
platform: process.platform,
|
||||
mode: 'operator'
|
||||
},
|
||||
role: 'operator',
|
||||
scopes: ['operator.read', 'operator.write'],
|
||||
auth: this.token ? { token: this.token } : {},
|
||||
device: {
|
||||
id: this.getDeviceId(),
|
||||
publicKey: '',
|
||||
signature: '',
|
||||
signedAt: Date.now(),
|
||||
nonce: uuidv4()
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.send(connectMessage);
|
||||
}
|
||||
|
||||
send(message) {
|
||||
if (this.socket && this.socket.readyState === WebSocket.OPEN) {
|
||||
this.socket.send(JSON.stringify(message));
|
||||
}
|
||||
}
|
||||
|
||||
nextId() {
|
||||
return `msg_${++this.messageId}_${Date.now()}`;
|
||||
}
|
||||
|
||||
getDeviceId() {
|
||||
return `wecome-client_${process.platform}_${uuidv4()}`;
|
||||
}
|
||||
|
||||
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 } = botConfig;
|
||||
|
||||
if (wecomClients.has(botId)) {
|
||||
wecomClients.get(botId).disconnect();
|
||||
}
|
||||
|
||||
const connection = new WeComConnection(botId, secret, (eventType, data) => {
|
||||
mainWindow.webContents.send('wecom-event', { eventType, data });
|
||||
});
|
||||
|
||||
wecomClients.set(botId, connection);
|
||||
connection.connect();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// 断开企业微信 Bot
|
||||
ipcMain.handle('disconnect-wecom', (event, botId) => {
|
||||
if (wecomClients.has(botId)) {
|
||||
wecomClients.get(botId).disconnect();
|
||||
wecomClients.delete(botId);
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// 连接 OpenClaw
|
||||
ipcMain.handle('connect-openclaw', (event, config) => {
|
||||
const { url, token } = config;
|
||||
|
||||
if (openclawSocket) {
|
||||
openclawSocket.disconnect();
|
||||
}
|
||||
|
||||
openclawSocket = new OpenClawConnection(url, token, (eventType, data) => {
|
||||
mainWindow.webContents.send('openclaw-event', { eventType, data });
|
||||
});
|
||||
|
||||
openclawSocket.connect();
|
||||
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// 断开 OpenClaw
|
||||
ipcMain.handle('disconnect-openclaw', () => {
|
||||
if (openclawSocket) {
|
||||
openclawSocket.disconnect();
|
||||
openclawSocket = null;
|
||||
}
|
||||
return { success: true };
|
||||
});
|
||||
|
||||
// 获取连接状态
|
||||
ipcMain.handle('get-connection-status', () => {
|
||||
const wecomStatus = {};
|
||||
wecomClients.forEach((conn, botId) => {
|
||||
wecomStatus[botId] = {
|
||||
connected: conn.isConnected,
|
||||
reconnectAttempts: conn.reconnectAttempts
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
wecom: wecomStatus,
|
||||
openclaw: openclawSocket ? { connected: openclawSocket.isConnected } : { connected: false }
|
||||
};
|
||||
});
|
||||
|
||||
// 发送消息到企业微信
|
||||
ipcMain.handle('send-wecom-message', (event, botId, message) => {
|
||||
if (!wecomClients.has(botId)) {
|
||||
return { success: false, error: 'Bot not connected' };
|
||||
}
|
||||
|
||||
const connection = wecomClients.get(botId);
|
||||
return connection.sendCommand('aibot_send_msg', message)
|
||||
.then(() => ({ success: true }))
|
||||
.catch(error => ({ success: false, error: error.message }));
|
||||
});
|
||||
|
||||
// 选择配置文件
|
||||
ipcMain.handle('select-file', async (event, options) => {
|
||||
const result = await dialog.showOpenDialog(mainWindow, options);
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
// 应用生命周期
|
||||
app.whenReady().then(() => {
|
||||
createWindow();
|
||||
setupIpcHandlers();
|
||||
|
||||
app.on('activate', () => {
|
||||
if (BrowserWindow.getAllWindows().length === 0) {
|
||||
createWindow();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
app.on('window-all-closed', () => {
|
||||
// 清理所有连接
|
||||
wecomClients.forEach(conn => conn.disconnect());
|
||||
if (openclawSocket) {
|
||||
openclawSocket.disconnect();
|
||||
}
|
||||
|
||||
if (process.platform !== 'darwin') {
|
||||
app.quit();
|
||||
}
|
||||
});
|
||||
|
||||
app.on('quit', () => {
|
||||
// 清理所有连接
|
||||
wecomClients.forEach(conn => conn.disconnect());
|
||||
if (openclawSocket) {
|
||||
openclawSocket.disconnect();
|
||||
}
|
||||
});
|
||||
|
||||
// 安全策略
|
||||
app.commandLine.appendSwitch('disable-features', 'OutOfBlinkCors');
|
||||
39
electron/preload.js
Normal file
39
electron/preload.js
Normal file
@@ -0,0 +1,39 @@
|
||||
const { contextBridge, ipcRenderer } = require('electron');
|
||||
|
||||
// 暴露安全的 API 给渲染进程
|
||||
contextBridge.exposeInMainWorld('electronAPI', {
|
||||
// 配置管理
|
||||
getConfig: () => ipcRenderer.invoke('get-config'),
|
||||
saveConfig: (config) => ipcRenderer.invoke('save-config', config),
|
||||
|
||||
// 企业微信连接
|
||||
connectWeCom: (botConfig) => ipcRenderer.invoke('connect-wecom', botConfig),
|
||||
disconnectWeCom: (botId) => ipcRenderer.invoke('disconnect-wecom', botId),
|
||||
|
||||
// OpenClaw 连接
|
||||
connectOpenClaw: (config) => ipcRenderer.invoke('connect-openclaw', config),
|
||||
disconnectOpenClaw: () => ipcRenderer.invoke('disconnect-openclaw'),
|
||||
|
||||
// 连接状态
|
||||
getConnectionStatus: () => ipcRenderer.invoke('get-connection-status'),
|
||||
|
||||
// 消息发送
|
||||
sendWeComMessage: (botId, message) => ipcRenderer.invoke('send-wecom-message', botId, message),
|
||||
|
||||
// 文件选择
|
||||
selectFile: (options) => ipcRenderer.invoke('select-file', options),
|
||||
|
||||
// 事件监听
|
||||
onWeComEvent: (callback) => {
|
||||
ipcRenderer.on('wecom-event', (event, data) => callback(data));
|
||||
},
|
||||
|
||||
onOpenClawEvent: (callback) => {
|
||||
ipcRenderer.on('openclaw-event', (event, data) => callback(data));
|
||||
},
|
||||
|
||||
// 移除事件监听
|
||||
removeAllListeners: (channel) => {
|
||||
ipcRenderer.removeAllListeners(channel);
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user