From cf3c22adc7d8fb23108e14963f80e923d3a5bca6 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Tue, 10 Feb 2026 11:39:17 +0800 Subject: [PATCH 1/3] feat(channel): remove slack channel support --- electron/gateway/client.ts | 76 ++++++++++++++++---------------- electron/utils/channel-config.ts | 53 ---------------------- src/pages/Dashboard/index.tsx | 31 +++++++------ src/types/channel.ts | 38 +--------------- 4 files changed, 55 insertions(+), 143 deletions(-) diff --git a/electron/gateway/client.ts b/electron/gateway/client.ts index 27e0ece..78cdc2a 100644 --- a/electron/gateway/client.ts +++ b/electron/gateway/client.ts @@ -7,7 +7,7 @@ import { GatewayManager, GatewayStatus } from './manager'; /** * Channel types supported by OpenClaw */ -export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'slack' | 'wechat'; +export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'wechat'; /** * Channel status @@ -107,223 +107,223 @@ export interface ProviderConfig { * Typed wrapper around GatewayManager for making RPC calls */ export class GatewayClient { - constructor(private manager: GatewayManager) {} - + constructor(private manager: GatewayManager) { } + /** * Get current gateway status */ getStatus(): GatewayStatus { return this.manager.getStatus(); } - + /** * Check if gateway is connected */ isConnected(): boolean { return this.manager.isConnected(); } - + // ==================== Channel Methods ==================== - + /** * List all channels */ async listChannels(): Promise { return this.manager.rpc('channels.list'); } - + /** * Get channel by ID */ async getChannel(channelId: string): Promise { return this.manager.rpc('channels.get', { channelId }); } - + /** * Connect a channel */ async connectChannel(channelId: string): Promise { return this.manager.rpc('channels.connect', { channelId }); } - + /** * Disconnect a channel */ async disconnectChannel(channelId: string): Promise { return this.manager.rpc('channels.disconnect', { channelId }); } - + /** * Get QR code for channel connection (e.g., WhatsApp) */ async getChannelQRCode(channelType: ChannelType): Promise { return this.manager.rpc('channels.getQRCode', { channelType }); } - + // ==================== Skill Methods ==================== - + /** * List all skills */ async listSkills(): Promise { return this.manager.rpc('skills.list'); } - + /** * Enable a skill */ async enableSkill(skillId: string): Promise { return this.manager.rpc('skills.enable', { skillId }); } - + /** * Disable a skill */ async disableSkill(skillId: string): Promise { return this.manager.rpc('skills.disable', { skillId }); } - + /** * Get skill configuration */ async getSkillConfig(skillId: string): Promise> { return this.manager.rpc>('skills.getConfig', { skillId }); } - + /** * Update skill configuration */ async updateSkillConfig(skillId: string, config: Record): Promise { return this.manager.rpc('skills.updateConfig', { skillId, config }); } - + // ==================== Chat Methods ==================== - + /** * Send a chat message */ async sendMessage(content: string, channelId?: string): Promise { return this.manager.rpc('chat.send', { content, channelId }); } - + /** * Get chat history */ async getChatHistory(limit = 50, offset = 0): Promise { return this.manager.rpc('chat.history', { limit, offset }); } - + /** * Clear chat history */ async clearChatHistory(): Promise { return this.manager.rpc('chat.clear'); } - + // ==================== Cron Methods ==================== - + /** * List all cron tasks */ async listCronTasks(): Promise { return this.manager.rpc('cron.list'); } - + /** * Create a new cron task */ async createCronTask(task: Omit): Promise { return this.manager.rpc('cron.create', task); } - + /** * Update a cron task */ async updateCronTask(taskId: string, updates: Partial): Promise { return this.manager.rpc('cron.update', { taskId, ...updates }); } - + /** * Delete a cron task */ async deleteCronTask(taskId: string): Promise { return this.manager.rpc('cron.delete', { taskId }); } - + /** * Run a cron task immediately */ async runCronTask(taskId: string): Promise { return this.manager.rpc('cron.run', { taskId }); } - + // ==================== Provider Methods ==================== - + /** * List configured AI providers */ async listProviders(): Promise { return this.manager.rpc('providers.list'); } - + /** * Add or update a provider */ async setProvider(provider: ProviderConfig): Promise { return this.manager.rpc('providers.set', provider); } - + /** * Remove a provider */ async removeProvider(providerId: string): Promise { return this.manager.rpc('providers.remove', { providerId }); } - + /** * Test provider connection */ async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> { return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId }); } - + // ==================== System Methods ==================== - + /** * Get Gateway health status */ async getHealth(): Promise<{ status: string; uptime: number; version?: string }> { return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health'); } - + /** * Get Gateway configuration */ async getConfig(): Promise> { return this.manager.rpc>('system.config'); } - + /** * Update Gateway configuration */ async updateConfig(config: Record): Promise { return this.manager.rpc('system.updateConfig', config); } - + /** * Get Gateway version info */ async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> { return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version'); } - + /** * Get available skill bundles */ async getSkillBundles(): Promise { return this.manager.rpc('skills.bundles'); } - + /** * Install a skill bundle */ diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 89b5b4c..0931c56 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -266,8 +266,6 @@ export async function validateChannelCredentials( return validateDiscordCredentials(config); case 'telegram': return validateTelegramCredentials(config); - case 'slack': - return validateSlackCredentials(config); default: // For channels without specific validation, just check required fields are present return { valid: true, errors: [], warnings: ['No online validation available for this channel type.'] }; @@ -424,58 +422,7 @@ async function validateTelegramCredentials( } } -/** - * Validate Slack bot token - */ -async function validateSlackCredentials( - config: Record -): Promise { - const botToken = config.botToken?.trim(); - - if (!botToken) { - return { valid: false, errors: ['Bot token is required'], warnings: [] }; - } - try { - const response = await fetch('https://slack.com/api/auth.test', { - method: 'POST', - headers: { - Authorization: `Bearer ${botToken}`, - 'Content-Type': 'application/x-www-form-urlencoded', - }, - }); - - const data = (await response.json()) as { ok?: boolean; error?: string; team?: string; user?: string }; - - if (data.ok) { - return { - valid: true, - errors: [], - warnings: [], - details: { team: data.team || 'Unknown', user: data.user || 'Unknown' }, - }; - } - - const errorMap: Record = { - invalid_auth: 'Invalid bot token', - account_inactive: 'Account is inactive', - token_revoked: 'Token has been revoked', - not_authed: 'No authentication token provided', - }; - - return { - valid: false, - errors: [errorMap[data.error || ''] || `Slack error: ${data.error}`], - warnings: [], - }; - } catch (error) { - return { - valid: false, - errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], - warnings: [], - }; - } -} /** * Validate channel configuration using OpenClaw doctor diff --git a/src/pages/Dashboard/index.tsx b/src/pages/Dashboard/index.tsx index 031fccc..b269648 100644 --- a/src/pages/Dashboard/index.tsx +++ b/src/pages/Dashboard/index.tsx @@ -28,10 +28,10 @@ export function Dashboard() { const { channels, fetchChannels } = useChannelsStore(); const { skills, fetchSkills } = useSkillsStore(); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); - + const isGatewayRunning = gatewayStatus.state === 'running'; const [uptime, setUptime] = useState(0); - + // Fetch data only when gateway is running useEffect(() => { if (isGatewayRunning) { @@ -39,11 +39,11 @@ export function Dashboard() { fetchSkills(); } }, [fetchChannels, fetchSkills, isGatewayRunning]); - + // Calculate statistics safely const connectedChannels = Array.isArray(channels) ? channels.filter((c) => c.status === 'connected').length : 0; const enabledSkills = Array.isArray(skills) ? skills.filter((s) => s.enabled).length : 0; - + // Update uptime periodically useEffect(() => { const updateUptime = () => { @@ -53,13 +53,13 @@ export function Dashboard() { setUptime(0); } }; - + // Update immediately updateUptime(); - + // Update every second const interval = setInterval(updateUptime, 1000); - + return () => clearInterval(interval); }, [gatewayStatus.connectedAt]); @@ -79,7 +79,7 @@ export function Dashboard() { console.error('Error opening Dev Console:', err); } }; - + return (
{/* Status Cards */} @@ -101,7 +101,7 @@ export function Dashboard() { )} - + {/* Channels */} @@ -115,7 +115,7 @@ export function Dashboard() {

- + {/* Skills */} @@ -129,7 +129,7 @@ export function Dashboard() {

- + {/* Uptime */} @@ -146,7 +146,7 @@ export function Dashboard() {
- + {/* Quick Actions */} @@ -192,7 +192,7 @@ export function Dashboard() { - + {/* Recent Activity */}
{/* Connected Channels */} @@ -221,7 +221,6 @@ export function Dashboard() { {channel.type === 'whatsapp' && '📱'} {channel.type === 'telegram' && '✈️'} {channel.type === 'discord' && '🎮'} - {channel.type === 'slack' && '💼'}

{channel.name}

@@ -237,7 +236,7 @@ export function Dashboard() { )} - + {/* Enabled Skills */} @@ -284,7 +283,7 @@ function formatUptime(seconds: number): string { const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); - + if (days > 0) { return `${days}d ${hours}h`; } else if (hours > 0) { diff --git a/src/types/channel.ts b/src/types/channel.ts index 0e451c8..360cfe3 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -10,7 +10,6 @@ export type ChannelType = | 'whatsapp' | 'telegram' | 'discord' - | 'slack' | 'signal' | 'feishu' | 'imessage' @@ -81,7 +80,6 @@ export const CHANNEL_ICONS: Record = { whatsapp: '📱', telegram: '✈️', discord: '🎮', - slack: '💼', signal: '🔒', feishu: '🐦', imessage: '💬', @@ -99,7 +97,6 @@ export const CHANNEL_NAMES: Record = { whatsapp: 'WhatsApp', telegram: 'Telegram', discord: 'Discord', - slack: 'Slack', signal: 'Signal', feishu: 'Feishu / Lark', imessage: 'iMessage', @@ -180,38 +177,7 @@ export const CHANNEL_META: Record = { 'Paste the bot token below', ], }, - slack: { - id: 'slack', - name: 'Slack', - icon: '💼', - description: 'Connect Slack using bot and app tokens', - connectionType: 'token', - docsUrl: 'https://docs.openclaw.ai/channels/slack', - configFields: [ - { - key: 'botToken', - label: 'Bot Token (xoxb-...)', - type: 'password', - placeholder: 'xoxb-...', - required: true, - envVar: 'SLACK_BOT_TOKEN', - }, - { - key: 'appToken', - label: 'App Token (xapp-...)', - type: 'password', - placeholder: 'xapp-...', - required: false, - envVar: 'SLACK_APP_TOKEN', - }, - ], - instructions: [ - 'Go to api.slack.com/apps', - 'Create a new app from scratch', - 'Add required OAuth scopes', - 'Install to workspace and copy tokens', - ], - }, + whatsapp: { id: 'whatsapp', name: 'WhatsApp', @@ -465,7 +431,7 @@ export const CHANNEL_META: Record = { * Get primary supported channels (non-plugin, commonly used) */ export function getPrimaryChannels(): ChannelType[] { - return ['telegram', 'discord', 'slack', 'whatsapp', 'feishu']; + return ['telegram', 'discord', 'whatsapp', 'feishu']; } /** From 7711eb692e67b4cec5c9a8e20a722f93e7fb9bc2 Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:00:29 +0800 Subject: [PATCH 2/3] fix whatsApp QR code --- electron/main/ipc-handlers.ts | 51 +++++ electron/preload/index.ts | 7 + electron/utils/whatsapp-login.ts | 349 +++++++++++++++++++++++++++++++ src/pages/Channels/index.tsx | 75 +++++-- 4 files changed, 467 insertions(+), 15 deletions(-) create mode 100644 electron/utils/whatsapp-login.ts diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index cb5f23f..f08cfd6 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -36,6 +36,7 @@ import { } from '../utils/channel-config'; import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup'; import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; +import { whatsAppLoginManager } from '../utils/whatsapp-login'; /** * Register all IPC handlers @@ -80,6 +81,9 @@ export function registerIpcHandlers( // Window control handlers (for custom title bar on Windows/Linux) registerWindowHandlers(mainWindow); + + // WhatsApp handlers + registerWhatsAppHandlers(mainWindow); } /** @@ -591,6 +595,53 @@ function registerOpenClawHandlers(): void { }); } +/** + * WhatsApp Login Handlers + */ +function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { + // Request WhatsApp QR code + ipcMain.handle('channel:requestWhatsAppQr', async (_, accountId: string) => { + try { + await whatsAppLoginManager.start(accountId); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + // Cancel WhatsApp login + ipcMain.handle('channel:cancelWhatsAppQr', async () => { + try { + await whatsAppLoginManager.stop(); + return { success: true }; + } catch (error) { + return { success: false, error: String(error) }; + } + }); + + // Check WhatsApp status (is it active?) + // ipcMain.handle('channel:checkWhatsAppStatus', ...) + + // Forward events to renderer + whatsAppLoginManager.on('qr', (data) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('channel:whatsapp-qr', data); + } + }); + + whatsAppLoginManager.on('success', (data) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('channel:whatsapp-success', data); + } + }); + + whatsAppLoginManager.on('error', (error) => { + if (!mainWindow.isDestroyed()) { + mainWindow.webContents.send('channel:whatsapp-error', error); + } + }); +} + /** * Provider-related IPC handlers diff --git a/electron/preload/index.ts b/electron/preload/index.ts index 2261347..52d2a96 100644 --- a/electron/preload/index.ts +++ b/electron/preload/index.ts @@ -91,7 +91,11 @@ const electronAPI = { 'channel:listConfigured', 'channel:setEnabled', 'channel:validate', + 'channel:validate', 'channel:validateCredentials', + // WhatsApp + 'channel:requestWhatsAppQr', + 'channel:cancelWhatsAppQr', // ClawHub 'clawhub:search', 'clawhub:install', @@ -132,6 +136,9 @@ const electronAPI = { 'gateway:notification', 'gateway:channel-status', 'gateway:chat-message', + 'channel:whatsapp-qr', + 'channel:whatsapp-success', + 'channel:whatsapp-error', 'gateway:exit', 'gateway:error', 'navigate', diff --git a/electron/utils/whatsapp-login.ts b/electron/utils/whatsapp-login.ts new file mode 100644 index 0000000..0f641ab --- /dev/null +++ b/electron/utils/whatsapp-login.ts @@ -0,0 +1,349 @@ +import { join, resolve } from 'path'; +import { homedir } from 'os'; +import { createRequire } from 'module'; +import { app } from 'electron'; +import { EventEmitter } from 'events'; +import { existsSync, mkdirSync, rmSync } from 'fs'; +import { deflateSync } from 'zlib'; + +const require = createRequire(import.meta.url); + +// Resolve paths to dependencies in openclaw/node_modules +const openclawPath = app.isPackaged + ? join(process.resourcesPath, 'openclaw') + : resolve(__dirname, '../../openclaw'); + +const baileysPath = resolve(openclawPath, 'node_modules', '@whiskeysockets', 'baileys'); +const qrcodeTerminalPath = resolve(openclawPath, 'node_modules', 'qrcode-terminal'); + +// Load Baileys dependencies dynamically +const { + default: makeWASocket, + useMultiFileAuthState: initAuth, // Rename to avoid React hook linter error + DisconnectReason, + fetchLatestBaileysVersion +} = require(baileysPath); + +// Load QRCode dependencies dynamically +const QRCodeModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'index.js')); +const QRErrorCorrectLevelModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'QRErrorCorrectLevel.js')); + +// Types from Baileys (approximate since we don't have types for dynamic require) +type BaileysSocket = any; +type ConnectionState = { + connection: 'close' | 'open' | 'connecting'; + lastDisconnect?: { + error?: Error & { output?: { statusCode?: number } }; + }; + qr?: string; +}; + +// --- QR Generation Logic (Adapted from OpenClaw) --- + +const QRCode = QRCodeModule; +const QRErrorCorrectLevel = QRErrorCorrectLevelModule; + +function createQrMatrix(input: string) { + const qr = new QRCode(-1, QRErrorCorrectLevel.L); + qr.addData(input); + qr.make(); + return qr; +} + +function fillPixel( + buf: Buffer, + x: number, + y: number, + width: number, + r: number, + g: number, + b: number, + a = 255, +) { + const idx = (y * width + x) * 4; + buf[idx] = r; + buf[idx + 1] = g; + buf[idx + 2] = b; + buf[idx + 3] = a; +} + +function crcTable() { + const table = new Uint32Array(256); + for (let i = 0; i < 256; i += 1) { + let c = i; + for (let k = 0; k < 8; k += 1) { + c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1; + } + table[i] = c >>> 0; + } + return table; +} + +const CRC_TABLE = crcTable(); + +function crc32(buf: Buffer) { + let crc = 0xffffffff; + for (let i = 0; i < buf.length; i += 1) { + crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8); + } + return (crc ^ 0xffffffff) >>> 0; +} + +function pngChunk(type: string, data: Buffer) { + const typeBuf = Buffer.from(type, 'ascii'); + const len = Buffer.alloc(4); + len.writeUInt32BE(data.length, 0); + const crc = crc32(Buffer.concat([typeBuf, data])); + const crcBuf = Buffer.alloc(4); + crcBuf.writeUInt32BE(crc, 0); + return Buffer.concat([len, typeBuf, data, crcBuf]); +} + +function encodePngRgba(buffer: Buffer, width: number, height: number) { + const stride = width * 4; + const raw = Buffer.alloc((stride + 1) * height); + for (let row = 0; row < height; row += 1) { + const rawOffset = row * (stride + 1); + raw[rawOffset] = 0; // filter: none + buffer.copy(raw, rawOffset + 1, row * stride, row * stride + stride); + } + const compressed = deflateSync(raw); + + const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]); + const ihdr = Buffer.alloc(13); + ihdr.writeUInt32BE(width, 0); + ihdr.writeUInt32BE(height, 4); + ihdr[8] = 8; // bit depth + ihdr[9] = 6; // color type RGBA + ihdr[10] = 0; // compression + ihdr[11] = 0; // filter + ihdr[12] = 0; // interlace + + return Buffer.concat([ + signature, + pngChunk('IHDR', ihdr), + pngChunk('IDAT', compressed), + pngChunk('IEND', Buffer.alloc(0)), + ]); +} + +async function renderQrPngBase64( + input: string, + opts: { scale?: number; marginModules?: number } = {}, +): Promise { + const { scale = 6, marginModules = 4 } = opts; + const qr = createQrMatrix(input); + const modules = qr.getModuleCount(); + const size = (modules + marginModules * 2) * scale; + + const buf = Buffer.alloc(size * size * 4, 255); + for (let row = 0; row < modules; row += 1) { + for (let col = 0; col < modules; col += 1) { + if (!qr.isDark(row, col)) { + continue; + } + const startX = (col + marginModules) * scale; + const startY = (row + marginModules) * scale; + for (let y = 0; y < scale; y += 1) { + const pixelY = startY + y; + for (let x = 0; x < scale; x += 1) { + const pixelX = startX + x; + fillPixel(buf, pixelX, pixelY, size, 0, 0, 0, 255); + } + } + } + } + + const png = encodePngRgba(buf, size, size); + return png.toString('base64'); +} + +// --- WhatsApp Login Manager --- + +export class WhatsAppLoginManager extends EventEmitter { + private socket: BaileysSocket | null = null; + private qr: string | null = null; + private accountId: string | null = null; + private active: boolean = false; + private retryCount: number = 0; + private maxRetries: number = 5; + + constructor() { + super(); + } + + /** + * Start WhatsApp pairing process + */ + async start(accountId: string = 'default'): Promise { + if (this.active && this.accountId === accountId) { + // Already running for this account, emit current QR if available + if (this.qr) { + const base64 = await renderQrPngBase64(this.qr); + this.emit('qr', { qr: base64, raw: this.qr }); + } + return; + } + + // Stop existing if different account or restart requested + if (this.active) { + await this.stop(); + } + + this.accountId = accountId; + this.active = true; + this.qr = null; + this.retryCount = 0; + + await this.connectToWhatsApp(accountId); + } + + private async connectToWhatsApp(accountId: string): Promise { + if (!this.active) return; + + try { + // Path where OpenClaw expects WhatsApp credentials + const authDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp', accountId); + + // Ensure directory exists + if (!existsSync(authDir)) { + mkdirSync(authDir, { recursive: true }); + } + + console.log(`[WhatsAppLogin] Connecting for ${accountId} at ${authDir} (Attempt ${this.retryCount + 1})`); + + let pino: any; + try { + // Try to resolve pino from baileys context since it's a dependency of baileys + const baileysRequire = createRequire(join(baileysPath, 'package.json')); + pino = baileysRequire('pino'); + } catch (e) { + console.warn('[WhatsAppLogin] Could not load pino from baileys, trying root', e); + try { + pino = require('pino'); + } catch { + console.warn('[WhatsAppLogin] Pino not found, using console fallback'); + // Mock pino logger if missing + pino = () => ({ + trace: () => { }, + debug: () => { }, + info: () => { }, + warn: () => { }, + error: () => { }, + fatal: () => { }, + child: () => pino(), + }); + } + } + + console.log('[WhatsAppLogin] Loading auth state...'); + const { state, saveCreds } = await initAuth(authDir); + + console.log('[WhatsAppLogin] Fetching latest version...'); + const { version } = await fetchLatestBaileysVersion(); + + console.log(`[WhatsAppLogin] Starting login for ${accountId}, version: ${version}`); + + this.socket = makeWASocket({ + version, + auth: state, + printQRInTerminal: false, + logger: pino({ level: 'silent' }), // Silent logger + connectTimeoutMs: 60000, + // mobile: false, + // browser: ['ClawX', 'Chrome', '1.0.0'], + }); + + this.socket.ev.on('creds.update', saveCreds); + + this.socket.ev.on('connection.update', async (update: ConnectionState) => { + try { + const { connection, lastDisconnect, qr } = update; + + if (qr) { + this.qr = qr; + console.log('[WhatsAppLogin] QR received'); + const base64 = await renderQrPngBase64(qr); + if (this.active) this.emit('qr', { qr: base64, raw: qr }); + } + + if (connection === 'close') { + const error = (lastDisconnect?.error as any); + const shouldReconnect = error?.output?.statusCode !== DisconnectReason.loggedOut; + console.log('[WhatsAppLogin] Connection closed.', + 'Reconnect:', shouldReconnect, + 'Active:', this.active, + 'Error:', error?.message + ); + + if (shouldReconnect && this.active) { + if (this.retryCount < this.maxRetries) { + this.retryCount++; + console.log(`[WhatsAppLogin] Reconnecting in 1s... (Attempt ${this.retryCount}/${this.maxRetries})`); + setTimeout(() => this.connectToWhatsApp(accountId), 1000); + } else { + console.log('[WhatsAppLogin] Max retries reached, stopping.'); + this.active = false; + this.emit('error', 'Connection failed after multiple retries'); + } + } else { + // Logged out or explicitly stopped + this.active = false; + if (error?.output?.statusCode === DisconnectReason.loggedOut) { + try { + rmSync(authDir, { recursive: true, force: true }); + } catch { } + } + if (this.socket) { + this.socket.end(undefined); + this.socket = null; + } + this.emit('error', 'Logged out'); + } + } else if (connection === 'open') { + console.log('[WhatsAppLogin] Connection opened! Closing socket to hand over to Gateway...'); + this.retryCount = 0; + + // Close socket gracefully to avoid conflict with Gateway + await this.stop(); + + this.emit('success', { accountId }); + } + } catch (innerErr) { + console.error('[WhatsAppLogin] Error in connection update:', innerErr); + } + }); + + } catch (error) { + console.error('[WhatsAppLogin] Fatal Connect Error:', error); + if (this.active && this.retryCount < this.maxRetries) { + this.retryCount++; + setTimeout(() => this.connectToWhatsApp(accountId), 2000); + } else { + this.active = false; + const msg = error instanceof Error ? error.message : String(error); + this.emit('error', msg); + } + } + } + + /** + * Stop current login process + */ + async stop(): Promise { + this.active = false; + this.qr = null; + if (this.socket) { + try { + // Remove listeners to prevent handling closure as error + this.socket.ev.removeAllListeners('connection.update'); + this.socket.end(undefined); + } catch { + // Ignore error if socket already closed + } + this.socket = null; + } + } +} + +export const whatsAppLoginManager = new WhatsAppLoginManager(); diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 21b09f0..22bebd8 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -369,6 +369,10 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded setConfigValues({}); setChannelName(''); setIsExistingConfig(false); + setChannelName(''); + setIsExistingConfig(false); + // Ensure we clean up any pending QR session if switching away + window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { }); return; } @@ -404,6 +408,47 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded return () => { cancelled = true; }; }, [selectedType]); + // Listen for WhatsApp QR events + useEffect(() => { + if (selectedType !== 'whatsapp') return; + + const onQr = (data: { qr: string; raw: string }) => { + setQrCode(`data:image/png;base64,${data.qr}`); + }; + + const onSuccess = () => { + toast.success('WhatsApp connected successfully!'); + // Register the channel locally so it shows up immediately + addChannel({ + type: 'whatsapp', + name: channelName || 'WhatsApp', + }).then(() => { + // Restart gateway to pick up the new session + window.electron.ipcRenderer.invoke('gateway:restart').catch(console.error); + onChannelAdded(); + }); + }; + + const onError = (err: string) => { + console.error('WhatsApp Login Error:', err); + toast.error(`WhatsApp Login Failed: ${err}`); + setQrCode(null); + setConnecting(false); + }; + + const removeQrListener = (window.electron.ipcRenderer.on as any)('channel:whatsapp-qr', onQr); + const removeSuccessListener = (window.electron.ipcRenderer.on as any)('channel:whatsapp-success', onSuccess); + const removeErrorListener = (window.electron.ipcRenderer.on as any)('channel:whatsapp-error', onError); + + return () => { + if (typeof removeQrListener === 'function') removeQrListener(); + if (typeof removeSuccessListener === 'function') removeSuccessListener(); + if (typeof removeErrorListener === 'function') removeErrorListener(); + // Cancel when unmounting or switching types + window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { }); + }; + }, [selectedType, addChannel, channelName, onChannelAdded]); + const handleValidate = async () => { if (!selectedType) return; @@ -457,10 +502,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded try { // For QR-based channels, request QR code if (meta.connectionType === 'qr') { - // Simulate QR code generation (in real implementation, call Gateway) - await new Promise((resolve) => setTimeout(resolve, 1500)); - setQrCode('placeholder-qr'); - setConnecting(false); + const accountId = channelName.trim() || 'default'; + await window.electron.ipcRenderer.invoke('channel:requestWhatsAppQr', accountId); + // The QR code will be set via event listener return; } @@ -625,23 +669,24 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded ) : qrCode ? ( // QR Code display
-
-
- -
+
+ {qrCode.startsWith('data:image') ? ( + Scan QR Code + ) : ( +
+ +
+ )}

Scan this QR code with {meta?.name} to connect

- -
From 83e6333ac023661b2d32ad32e8db19477f5b6f0e Mon Sep 17 00:00:00 2001 From: paisley <8197966+su8su@users.noreply.github.com> Date: Tue, 10 Feb 2026 13:37:07 +0800 Subject: [PATCH 3/3] feat(channels): implement WhatsApp QR login proxy --- electron/utils/channel-config.ts | 48 ++++++++++++++++++++++++++++---- electron/utils/whatsapp-login.ts | 4 ++- src/types/channel.ts | 6 ++-- 3 files changed, 48 insertions(+), 10 deletions(-) diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 0931c56..bc2d350 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -2,7 +2,7 @@ * Channel Configuration Utilities * Manages channel configuration in OpenClaw config files */ -import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSync, rmSync } from 'fs'; import { join } from 'path'; import { homedir } from 'os'; @@ -201,6 +201,20 @@ export function deleteChannelConfig(channelType: string): void { writeOpenClawConfig(currentConfig); console.log(`Deleted channel config for ${channelType}`); } + + // Special handling for WhatsApp credentials + if (channelType === 'whatsapp') { + try { + + const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); + if (existsSync(whatsappDir)) { + rmSync(whatsappDir, { recursive: true, force: true }); + console.log('Deleted WhatsApp credentials directory'); + } + } catch (error) { + console.error('Failed to delete WhatsApp credentials:', error); + } + } } /** @@ -208,13 +222,35 @@ export function deleteChannelConfig(channelType: string): void { */ export function listConfiguredChannels(): string[] { const config = readOpenClawConfig(); - if (!config.channels) { - return []; + const channels: string[] = []; + + if (config.channels) { + channels.push(...Object.keys(config.channels).filter( + (channelType) => config.channels![channelType]?.enabled !== false + )); + } + + // Check for WhatsApp credentials directory + try { + const whatsappDir = join(homedir(), '.openclaw', 'credentials', 'whatsapp'); + if (existsSync(whatsappDir)) { + const entries = readdirSync(whatsappDir); + // Check if there's at least one directory (session) + const hasSession = entries.some((entry: string) => { + try { + return statSync(join(whatsappDir, entry)).isDirectory(); + } catch { return false; } + }); + + if (hasSession && !channels.includes('whatsapp')) { + channels.push('whatsapp'); + } + } + } catch { + // Ignore errors checking whatsapp dir } - return Object.keys(config.channels).filter( - (channelType) => config.channels![channelType]?.enabled !== false - ); + return channels; } /** diff --git a/electron/utils/whatsapp-login.ts b/electron/utils/whatsapp-login.ts index 0f641ab..62a20dc 100644 --- a/electron/utils/whatsapp-login.ts +++ b/electron/utils/whatsapp-login.ts @@ -292,7 +292,9 @@ export class WhatsAppLoginManager extends EventEmitter { if (error?.output?.statusCode === DisconnectReason.loggedOut) { try { rmSync(authDir, { recursive: true, force: true }); - } catch { } + } catch (err) { + console.error('[WhatsAppLogin] Failed to clear auth dir:', err); + } } if (this.socket) { this.socket.end(undefined); diff --git a/src/types/channel.ts b/src/types/channel.ts index 360cfe3..0a7c289 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -182,15 +182,15 @@ export const CHANNEL_META: Record = { id: 'whatsapp', name: 'WhatsApp', icon: '📱', - description: 'Connect WhatsApp by scanning a QR code', + description: 'Connect WhatsApp by scanning a QR code (no phone number required)', connectionType: 'qr', docsUrl: 'https://docs.openclaw.ai/channels/whatsapp', configFields: [], instructions: [ 'Open WhatsApp on your phone', - 'Go to Settings > Linked Devices', - 'Tap "Link a Device"', + 'Go to Settings > Linked Devices > Link a Device', 'Scan the QR code shown below', + 'The system will automatically identify your phone number', ], }, signal: {