Files
wecome-openclaw-client/electron/main.js
徐总 7c826af5d1 fix: 修改 sessionKey 格式为 botId:chatId
之前格式:wecom:chatType:chatId
现在格式:botId:chatId

例如:
- aib55ZBHxW398XZkgYgwFPp1cXwtxUWQQf7:group:123456789
- aib55ZBHxW398XZkgYgwFPp1cXwtxUWQQf7:direct:zhangsan

这样每个 Bot 的会话独立管理,符合 OpenClaw 会话管理规范。
2026-03-10 03:57:00 +08:00

694 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 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 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}`);
}
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}`);
} 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}` };
}
});
}
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');