feat: 新增 Gateway 配置保存和测试消息功能

新增功能:
- OpenClaw Gateway 配置支持修改保存(地址和 Token)
- 新增测试消息通信功能,可发送测试消息验证 Gateway 连接
- 添加 URL 清理按钮(移除末尾斜杠)
- 界面显示版本号 v1.0.0-fix7

修复:
- OpenClaw WebSocket 握手协议(等待 challenge 响应)
- 关闭窗口时事件处理器访问已销毁窗口的错误
- SSL/TLS 错误诊断和提示
- 连接状态管理优化

技术改进:
- 使用 challenge-response 握手机制连接 OpenClaw Gateway
- 添加窗口销毁检查避免事件发送失败
- 改进错误日志和诊断信息
- 优化连接状态更新逻辑
This commit is contained in:
2026-03-10 00:20:18 +08:00
parent 9cce1e76fc
commit fddd2b2e6b
7 changed files with 972 additions and 132 deletions

View File

@@ -1,4 +1,4 @@
const { app, BrowserWindow, ipcMain, dialog } = require('electron');
const { app, BrowserWindow, ipcMain, dialog, Tray, Menu, nativeImage } = require('electron');
const path = require('path');
const fs = require('fs');
const Store = require('electron-store');
@@ -10,7 +10,7 @@ const WebSocket = require('ws');
const store = new Store({
name: 'config',
defaults: {
bots: [], // 多 Bot 配置 [{botId, secret, name, enabled}]
bots: [], // 多 Bot 配置 [{id, botId, secret, name, enabled}]
openclaw: {
url: 'ws://localhost:18789',
token: '',
@@ -20,9 +20,10 @@ const store = new Store({
});
let mainWindow;
let wecomClients = new Map(); // botId -> WSClient
let openclawSocket = null;
let heartbeatInterval = null;
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() {
@@ -33,7 +34,8 @@ function createWindow() {
nodeIntegration: false,
contextIsolation: true,
preload: path.join(__dirname, 'preload.js')
}
},
icon: path.join(__dirname, '../resources/icon.png')
});
const isDev = process.env.NODE_ENV === 'development';
@@ -49,11 +51,34 @@ function createWindow() {
});
}
// 创建系统托盘
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(botId, secret, eventHandler) {
this.botId = botId;
this.secret = secret;
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;
@@ -63,61 +88,215 @@ class WeComConnection {
async connect() {
try {
console.log(`[WeCom] Connecting bot ${this.botId}...`);
this.client = new WSClient({
botId: this.botId,
secret: this.secret,
onConnected: () => {
this.isConnected = true;
this.reconnectAttempts = 0;
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 });
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);
}
});
// 监听认证成功
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] Bot ${this.botId} max reconnect attempts reached`);
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] Bot ${this.botId} reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
console.log(`[WeCom:${this.botId}] Reconnecting in ${delay}ms (attempt ${this.reconnectAttempts})`);
setTimeout(() => this.connect(), delay);
}
async sendCommand(cmd, body) {
// 发送消息到企业微信
async sendMessage(chatId, content, finish = true, streamId = null) {
if (!this.client || !this.isConnected) {
throw new Error('Not connected');
}
return await this.client.send({ cmd, headers: { req_id: uuidv4() }, body });
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);
}
}
}
@@ -125,7 +304,8 @@ class WeComConnection {
// OpenClaw Gateway WebSocket 连接
class OpenClawConnection {
constructor(url, token, eventHandler) {
this.url = url;
// 清理 URL - 移除末尾斜杠
this.url = url?.trim().replace(/\/+$/, '') || 'ws://localhost:18789';
this.token = token;
this.eventHandler = eventHandler;
this.socket = null;
@@ -133,26 +313,63 @@ class OpenClawConnection {
this.reconnectAttempts = 0;
this.maxReconnectAttempts = 100;
this.messageId = 0;
this.protocolVersion = 3;
this.deviceId = `wecome-client_${process.platform}_${uuidv4()}`;
}
async connect() {
try {
this.socket = new WebSocket(this.url);
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;
this.eventHandler('connected');
console.log('[OpenClaw] Connected');
console.log('[OpenClaw] WebSocket connected, waiting for challenge...');
// 发送 connect 请求
this.sendConnect();
// 检查窗口是否存在
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());
this.eventHandler('message', message);
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);
}
@@ -160,14 +377,34 @@ class OpenClawConnection {
this.socket.on('close', () => {
this.isConnected = false;
this.eventHandler('disconnected');
console.log('[OpenClaw] Disconnected');
// 检查窗口是否存在
if (mainWindow && !mainWindow.isDestroyed()) {
this.eventHandler('disconnected');
}
this.scheduleReconnect();
});
this.socket.on('error', (error) => {
this.eventHandler('error', { error: error.message });
console.error('[OpenClaw] 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) {
@@ -176,14 +413,15 @@ class OpenClawConnection {
}
}
sendConnect() {
// 发送 connect 握手(响应 challenge
sendConnect(nonce = null) {
const connectMessage = {
type: 'req',
id: this.nextId(),
method: 'connect',
params: {
minProtocol: 3,
maxProtocol: 3,
minProtocol: this.protocolVersion,
maxProtocol: this.protocolVersion,
client: {
id: 'wecome-client',
version: '1.0.0',
@@ -192,20 +430,77 @@ class OpenClawConnection {
},
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.getDeviceId(),
id: this.deviceId,
publicKey: '',
signature: '',
signedAt: Date.now(),
nonce: uuidv4()
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));
@@ -216,10 +511,6 @@ class OpenClawConnection {
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');
@@ -256,17 +547,17 @@ function setupIpcHandlers() {
// 连接企业微信 Bot
ipcMain.handle('connect-wecom', (event, botConfig) => {
const { botId, secret } = botConfig;
const { botId, secret, id } = botConfig;
if (wecomClients.has(botId)) {
wecomClients.get(botId).disconnect();
// 如果已存在,先断开
if (wecomConnections.has(botId)) {
wecomConnections.get(botId).disconnect();
}
const connection = new WeComConnection(botId, secret, (eventType, data) => {
const connection = new WeComConnection({ botId, secret, id }, (eventType, data) => {
mainWindow.webContents.send('wecom-event', { eventType, data });
});
wecomClients.set(botId, connection);
connection.connect();
return { success: true };
@@ -274,9 +565,10 @@ function setupIpcHandlers() {
// 断开企业微信 Bot
ipcMain.handle('disconnect-wecom', (event, botId) => {
if (wecomClients.has(botId)) {
wecomClients.get(botId).disconnect();
wecomClients.delete(botId);
const conn = wecomConnections.get(botId);
if (conn) {
conn.disconnect();
wecomConnections.delete(botId);
}
return { success: true };
});
@@ -285,24 +577,24 @@ function setupIpcHandlers() {
ipcMain.handle('connect-openclaw', (event, config) => {
const { url, token } = config;
if (openclawSocket) {
openclawSocket.disconnect();
if (openclawConnection) {
openclawConnection.disconnect();
}
openclawSocket = new OpenClawConnection(url, token, (eventType, data) => {
openclawConnection = new OpenClawConnection(url, token, (eventType, data) => {
mainWindow.webContents.send('openclaw-event', { eventType, data });
});
openclawSocket.connect();
openclawConnection.connect();
return { success: true };
});
// 断开 OpenClaw
ipcMain.handle('disconnect-openclaw', () => {
if (openclawSocket) {
openclawSocket.disconnect();
openclawSocket = null;
if (openclawConnection) {
openclawConnection.disconnect();
openclawConnection = null;
}
return { success: true };
});
@@ -310,7 +602,7 @@ function setupIpcHandlers() {
// 获取连接状态
ipcMain.handle('get-connection-status', () => {
const wecomStatus = {};
wecomClients.forEach((conn, botId) => {
wecomConnections.forEach((conn, botId) => {
wecomStatus[botId] = {
connected: conn.isConnected,
reconnectAttempts: conn.reconnectAttempts
@@ -319,34 +611,81 @@ function setupIpcHandlers() {
return {
wecom: wecomStatus,
openclaw: openclawSocket ? { connected: openclawSocket.isConnected } : { connected: false }
openclaw: openclawConnection ? { connected: openclawConnection.isConnected } : { connected: false }
};
});
// 发送消息到企业微信
ipcMain.handle('send-wecom-message', (event, botId, message) => {
if (!wecomClients.has(botId)) {
// 测试消息
ipcMain.handle('send-test-message', async (event, botId, chatId, text) => {
const conn = wecomConnections.get(botId);
if (!conn) {
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 }));
try {
await conn.sendMessage(chatId, text, true);
return { success: true };
} catch (error) {
return { success: false, error: error.message };
}
});
// 选择配置文件
ipcMain.handle('select-file', async (event, options) => {
const result = await dialog.showOpenDialog(mainWindow, options);
return result;
// 测试 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();
@@ -356,9 +695,9 @@ app.whenReady().then(() => {
app.on('window-all-closed', () => {
// 清理所有连接
wecomClients.forEach(conn => conn.disconnect());
if (openclawSocket) {
openclawSocket.disconnect();
wecomConnections.forEach(conn => conn.disconnect());
if (openclawConnection) {
openclawConnection.disconnect();
}
if (process.platform !== 'darwin') {
@@ -368,9 +707,10 @@ app.on('window-all-closed', () => {
app.on('quit', () => {
// 清理所有连接
wecomClients.forEach(conn => conn.disconnect());
if (openclawSocket) {
openclawSocket.disconnect();
console.log('[App] Quitting, cleaning up connections...');
wecomConnections.forEach(conn => conn.disconnect());
if (openclawConnection) {
openclawConnection.disconnect();
}
});

View File

@@ -23,6 +23,10 @@ contextBridge.exposeInMainWorld('electronAPI', {
// 文件选择
selectFile: (options) => ipcRenderer.invoke('select-file', options),
// 测试消息
sendTestMessage: (botId, chatId, text) => ipcRenderer.invoke('send-test-message', botId, chatId, text),
sendTestOpenClawMessage: (text) => ipcRenderer.invoke('send-test-openclaw-message', text),
// 事件监听
onWeComEvent: (callback) => {
ipcRenderer.on('wecom-event', (event, data) => callback(data));