From 0324938b81dced056549d42e5effd7dc66edfb3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=BE=90=E6=80=BB?= Date: Tue, 10 Mar 2026 03:39:28 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BD=BF=E7=94=A8=E6=AD=A3=E7=A1=AE?= =?UTF-8?q?=E7=9A=84=20Gateway=20API=20-=20chat.send?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 关键修复: - 之前错误:直接发送 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 协议规范! --- electron/main-backup.js | 686 +++++++++++++++++++++++++++++++++++++ electron/main-chat-fix.js | 93 +++++ electron/main-final.js | 693 ++++++++++++++++++++++++++++++++++++++ electron/main.js | 55 +-- 4 files changed, 1503 insertions(+), 24 deletions(-) create mode 100644 electron/main-backup.js create mode 100644 electron/main-chat-fix.js create mode 100644 electron/main-final.js diff --git a/electron/main-backup.js b/electron/main-backup.js new file mode 100644 index 0000000..0d3de77 --- /dev/null +++ b/electron/main-backup.js @@ -0,0 +1,686 @@ +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'); diff --git a/electron/main-chat-fix.js b/electron/main-chat-fix.js new file mode 100644 index 0000000..5b80c66 --- /dev/null +++ b/electron/main-chat-fix.js @@ -0,0 +1,93 @@ +// 修复:使用正确的 Gateway API 发送消息 + +// 转发消息到 OpenClaw - 使用 chat.send 方法 +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(格式:wecom:chatType:chatId) + const sessionKey = `wecom:${chatType}:${chatId}`; + + // 处理媒体文件 + const attachments = []; + + // 下载图片 + if (body.image?.url) { + try { + console.log('[WeCom] Downloading image:', body.image.url); + const { buffer, filename } = await downloadFile(body.image.url, body.image.aeskey); + const saved = await saveMediaFile(buffer, filename, '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, filename } = await downloadFile(body.file.url, body.file.aeskey); + const saved = await saveMediaFile(buffer, filename, '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}`); +} diff --git a/electron/main-final.js b/electron/main-final.js new file mode 100644 index 0000000..16bbdba --- /dev/null +++ b/electron/main-final.js @@ -0,0 +1,693 @@ +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(格式:wecom:chatType:chatId) + const sessionKey = `wecom:${chatType}:${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'); diff --git a/electron/main.js b/electron/main.js index 0d3de77..16bbdba 100644 --- a/electron/main.js +++ b/electron/main.js @@ -131,6 +131,7 @@ class WeComConnection { } } + // 转发消息到 OpenClaw - 使用 chat.send API async forwardMessageToOpenClaw(frame) { if (!openclawConnection || !openclawConnection.isConnected) { console.log('[WeCom] OpenClaw not connected, skipping forward'); @@ -163,8 +164,11 @@ class WeComConnection { text = text.replace(/@\S+/g, '').trim(); } + // 构建 sessionKey(格式:wecom:chatType:chatId) + const sessionKey = `wecom:${chatType}:${chatId}`; + // 处理媒体文件 - const mediaList = []; + const attachments = []; // 下载图片 if (body.image?.url) { @@ -172,7 +176,11 @@ class WeComConnection { 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 }); + 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); @@ -185,35 +193,33 @@ class WeComConnection { 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 }); + 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); } } - // 构建转发消息 - 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 + // 使用 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 作为幂等键 } }; - openclawConnection.send(JSON.stringify(openclawMessage)); + console.log('[OpenClaw] Sending via chat.send:', sessionKey); + openclawConnection.send(JSON.stringify(chatSendMessage)); console.log(`[Forward] WeCom -> OpenClaw: ${messageId}`); } @@ -420,7 +426,7 @@ class OpenClawConnection { } }; - console.log('[OpenClaw] 发送 connect 请求:', JSON.stringify(connectMessage, null, 2)); + console.log('[OpenClaw] 发送 connect 请求'); this.send(connectMessage); } @@ -625,7 +631,8 @@ function setupIpcHandlers() { params: { text: text, sessionKey: `test_${Date.now()}`, - echo: true + deliver: true, + idempotencyKey: uuidv4() } }; console.log('[OpenClaw] Sending test message:', testMessage);