Files
wecome-openclaw-client/electron/main-backup.js
徐总 0324938b81 feat: 使用正确的 Gateway API - chat.send
关键修复:
- 之前错误:直接发送 message.inbound 事件
- 现在正确:使用 Gateway 的 chat.send 方法

Gateway API 调用:
{
  "type": "req",
  "method": "chat.send",
  "params": {
    "sessionKey": "wecom:group:chatId",
    "message": "文本内容",
    "attachments": [{"type": "image", "path": "..."}],
    "deliver": true,
    "idempotencyKey": "messageId"
  }
}

会话管理:
- sessionKey 格式:wecom:chatType:chatId
- 例如:wecom:group:123456789 或 wecom:direct:zhangsan
- OpenClaw 会自动创建或复用会话

媒体文件处理:
- 下载企业微信图片/文件
- 保存到本地媒体目录
- 通过 attachments 参数传递给 chat.send

这样才符合 OpenClaw Gateway 协议规范!
2026-03-10 03:39:28 +08:00

687 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();
}
}
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();
}
// 处理媒体文件
const mediaList = [];
// 下载图片
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');
mediaList.push({ type: 'image', path: saved.path });
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');
mediaList.push({ type: 'file', path: saved.path });
console.log('[WeCom] File saved:', saved.path);
} catch (error) {
console.error('[WeCom] Failed to download file:', error.message);
}
}
// 构建转发消息
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,
media: mediaList,
timestamp: body.create_time || Date.now()
},
reqId: reqId,
botId: this.botId
}
};
openclawConnection.send(JSON.stringify(openclawMessage));
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 请求:', JSON.stringify(connectMessage, null, 2));
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()}`,
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;
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');