diff --git a/electron/main/ipc-handlers.ts b/electron/main/ipc-handlers.ts index 4948ce29f..21493e899 100644 --- a/electron/main/ipc-handlers.ts +++ b/electron/main/ipc-handlers.ts @@ -532,6 +532,7 @@ function registerOpenClawHandlers(): void { // Save channel configuration ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record) => { try { + logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) }); saveChannelConfig(channelType, config); return { success: true }; } catch (error) { @@ -625,9 +626,11 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { // Request WhatsApp QR code ipcMain.handle('channel:requestWhatsAppQr', async (_, accountId: string) => { try { + logger.info('channel:requestWhatsAppQr', { accountId }); await whatsAppLoginManager.start(accountId); return { success: true }; } catch (error) { + logger.error('channel:requestWhatsAppQr failed', error); return { success: false, error: String(error) }; } }); @@ -638,6 +641,7 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { await whatsAppLoginManager.stop(); return { success: true }; } catch (error) { + logger.error('channel:cancelWhatsAppQr failed', error); return { success: false, error: String(error) }; } }); @@ -654,12 +658,14 @@ function registerWhatsAppHandlers(mainWindow: BrowserWindow): void { whatsAppLoginManager.on('success', (data) => { if (!mainWindow.isDestroyed()) { + logger.info('whatsapp:login-success', data); mainWindow.webContents.send('channel:whatsapp-success', data); } }); whatsAppLoginManager.on('error', (error) => { if (!mainWindow.isDestroyed()) { + logger.error('whatsapp:login-error', error); mainWindow.webContents.send('channel:whatsapp-error', error); } }); diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index 068c36671..5614363cc 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -6,17 +6,27 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, statSy import { join } from 'path'; import { homedir } from 'os'; import { getOpenClawResolvedDir } from './paths'; +import * as logger from './logger'; const OPENCLAW_DIR = join(homedir(), '.openclaw'); const CONFIG_FILE = join(OPENCLAW_DIR, 'openclaw.json'); +// Channels that are managed as plugins (config goes under plugins.entries, not channels) +const PLUGIN_CHANNELS = ['whatsapp']; + export interface ChannelConfigData { enabled?: boolean; [key: string]: unknown; } +export interface PluginsConfig { + entries?: Record; + [key: string]: unknown; +} + export interface OpenClawConfig { channels?: Record; + plugins?: PluginsConfig; [key: string]: unknown; } @@ -43,6 +53,7 @@ export function readOpenClawConfig(): OpenClawConfig { const content = readFileSync(CONFIG_FILE, 'utf-8'); return JSON.parse(content) as OpenClawConfig; } catch (error) { + logger.error('Failed to read OpenClaw config', error); console.error('Failed to read OpenClaw config:', error); return {}; } @@ -57,6 +68,7 @@ export function writeOpenClawConfig(config: OpenClawConfig): void { try { writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf-8'); } catch (error) { + logger.error('Failed to write OpenClaw config', error); console.error('Failed to write OpenClaw config:', error); throw error; } @@ -73,6 +85,28 @@ export function saveChannelConfig( ): void { const currentConfig = readOpenClawConfig(); + // Plugin-based channels (e.g. WhatsApp) go under plugins.entries, not channels + if (PLUGIN_CHANNELS.includes(channelType)) { + if (!currentConfig.plugins) { + currentConfig.plugins = {}; + } + if (!currentConfig.plugins.entries) { + currentConfig.plugins.entries = {}; + } + currentConfig.plugins.entries[channelType] = { + ...currentConfig.plugins.entries[channelType], + enabled: config.enabled ?? true, + }; + writeOpenClawConfig(currentConfig); + logger.info('Plugin channel config saved', { + channelType, + configFile: CONFIG_FILE, + path: `plugins.entries.${channelType}`, + }); + console.log(`Saved plugin channel config for ${channelType}`); + return; + } + if (!currentConfig.channels) { currentConfig.channels = {}; } @@ -146,6 +180,13 @@ export function saveChannelConfig( }; writeOpenClawConfig(currentConfig); + logger.info('Channel config saved', { + channelType, + configFile: CONFIG_FILE, + rawKeys: Object.keys(config), + transformedKeys: Object.keys(transformedConfig), + enabled: currentConfig.channels[channelType]?.enabled, + }); console.log(`Saved channel config for ${channelType}`); } @@ -289,6 +330,23 @@ export function listConfiguredChannels(): string[] { export function setChannelEnabled(channelType: string, enabled: boolean): void { const currentConfig = readOpenClawConfig(); + // Plugin-based channels go under plugins.entries + if (PLUGIN_CHANNELS.includes(channelType)) { + if (!currentConfig.plugins) { + currentConfig.plugins = {}; + } + if (!currentConfig.plugins.entries) { + currentConfig.plugins.entries = {}; + } + if (!currentConfig.plugins.entries[channelType]) { + currentConfig.plugins.entries[channelType] = {}; + } + currentConfig.plugins.entries[channelType].enabled = enabled; + writeOpenClawConfig(currentConfig); + console.log(`Set plugin channel ${channelType} enabled: ${enabled}`); + return; + } + if (!currentConfig.channels) { currentConfig.channels = {}; } @@ -457,10 +515,16 @@ async function validateTelegramCredentials( ): Promise { const botToken = config.botToken?.trim(); + const allowedUsers = config.allowedUsers?.trim(); + if (!botToken) { return { valid: false, errors: ['Bot token is required'], warnings: [] }; } + if (!allowedUsers) { + return { valid: false, errors: ['At least one allowed user ID is required'], warnings: [] }; + } + try { const response = await fetch(`https://api.telegram.org/bot${botToken}/getMe`); const data = (await response.json()) as { ok?: boolean; description?: string; result?: { username?: string } }; @@ -553,6 +617,12 @@ export async function validateChannelConfig(channelType: string): Promise; type ConnectionState = { connection: 'close' | 'open' | 'connecting'; lastDisconnect?: { @@ -186,6 +189,18 @@ export class WhatsAppLoginManager extends EventEmitter { super(); } + /** + * Finish login: close socket and emit success after credentials are saved + */ + private async finishLogin(accountId: string): Promise { + if (!this.active) return; + console.log('[WhatsAppLogin] Finishing login, closing socket to hand over to Gateway...'); + await this.stop(); + // Delay to ensure socket is fully released before Gateway connects + await new Promise(resolve => setTimeout(resolve, 2000)); + this.emit('success', { accountId }); + } + /** * Start WhatsApp pairing process */ @@ -226,7 +241,8 @@ export class WhatsAppLoginManager extends EventEmitter { console.log(`[WhatsAppLogin] Connecting for ${accountId} at ${authDir} (Attempt ${this.retryCount + 1})`); - let pino: any; + + let pino: (...args: unknown[]) => Record; try { // Try to resolve pino from baileys context since it's a dependency of baileys const baileysRequire = createRequire(join(baileysPath, 'package.json')); @@ -268,7 +284,21 @@ export class WhatsAppLoginManager extends EventEmitter { // browser: ['ClawX', 'Chrome', '1.0.0'], }); - this.socket.ev.on('creds.update', saveCreds); + let connectionOpened = false; + let credsReceived = false; + let credsTimeout: ReturnType | null = null; + + this.socket.ev.on('creds.update', async () => { + await saveCreds(); + if (connectionOpened && !credsReceived) { + credsReceived = true; + if (credsTimeout) clearTimeout(credsTimeout); + console.log('[WhatsAppLogin] Credentials saved after connection open, finishing login...'); + // Small delay to ensure file writes are fully flushed + await new Promise(resolve => setTimeout(resolve, 3000)); + await this.finishLogin(accountId); + } + }); this.socket.ev.on('connection.update', async (update: ConnectionState) => { try { @@ -282,7 +312,7 @@ export class WhatsAppLoginManager extends EventEmitter { } if (connection === 'close') { - const error = (lastDisconnect?.error as any); + const error = lastDisconnect?.error as BaileysError | undefined; const shouldReconnect = error?.output?.statusCode !== DisconnectReason.loggedOut; console.log('[WhatsAppLogin] Connection closed.', 'Reconnect:', shouldReconnect, @@ -317,17 +347,17 @@ export class WhatsAppLoginManager extends EventEmitter { this.emit('error', 'Logged out'); } } else if (connection === 'open') { - console.log('[WhatsAppLogin] Connection opened! Closing socket to hand over to Gateway...'); + console.log('[WhatsAppLogin] Connection opened! Waiting for credentials to be saved...'); this.retryCount = 0; + connectionOpened = true; - // Close socket gracefully to avoid conflict with Gateway - await this.stop(); - - // Add a small delay to ensure socket is fully closed and released - // This prevents "401 Conflict" when Gateway tries to connect immediately - await new Promise(resolve => setTimeout(resolve, 2000)); - - this.emit('success', { accountId }); + // Safety timeout: if creds don't update within 15s, proceed anyway + credsTimeout = setTimeout(async () => { + if (!credsReceived && this.active) { + console.warn('[WhatsAppLogin] Timed out waiting for creds.update after connection open, proceeding...'); + await this.finishLogin(accountId); + } + }, 15000); } } catch (innerErr) { console.error('[WhatsAppLogin] Error in connection update:', innerErr); diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index 22bebd842..4fd1fd2e1 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -412,12 +412,29 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded useEffect(() => { if (selectedType !== 'whatsapp') return; - const onQr = (data: { qr: string; raw: string }) => { + const onQr = (...args: unknown[]) => { + const data = args[0] as { qr: string; raw: string }; setQrCode(`data:image/png;base64,${data.qr}`); }; - const onSuccess = () => { + const onSuccess = async (...args: unknown[]) => { + const data = args[0] as { accountId?: string } | undefined; toast.success('WhatsApp connected successfully!'); + const accountId = data?.accountId || channelName.trim() || 'default'; + try { + const saveResult = await window.electron.ipcRenderer.invoke( + 'channel:saveConfig', + 'whatsapp', + { enabled: true } + ) as { success?: boolean; error?: string }; + if (!saveResult?.success) { + console.error('Failed to save WhatsApp config:', saveResult?.error); + } else { + console.info('Saved WhatsApp config for account:', accountId); + } + } catch (error) { + console.error('Failed to save WhatsApp config:', error); + } // Register the channel locally so it shows up immediately addChannel({ type: 'whatsapp', @@ -429,16 +446,17 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded }); }; - const onError = (err: string) => { + const onError = (...args: unknown[]) => { + const err = args[0] as 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); + const removeQrListener = window.electron.ipcRenderer.on('channel:whatsapp-qr', onQr); + const removeSuccessListener = window.electron.ipcRenderer.on('channel:whatsapp-success', onSuccess); + const removeErrorListener = window.electron.ipcRenderer.on('channel:whatsapp-error', onError); return () => { if (typeof removeQrListener === 'function') removeQrListener(); diff --git a/src/types/channel.ts b/src/types/channel.ts index 941ffcf09..88001d196 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -129,11 +129,11 @@ export const CHANNEL_META: Record = { }, { key: 'allowedUsers', - label: 'Allowed User IDs (optional)', + label: 'Allowed User IDs', type: 'text', placeholder: 'e.g. 123456789, 987654321', - description: 'Comma separated list of User IDs allowed to use the bot. Leave empty to allow everyone (if public) or require pairing.', - required: false, + description: 'Comma separated list of User IDs allowed to use the bot. Required for security.', + required: true, }, ], instructions: [ @@ -141,6 +141,7 @@ export const CHANNEL_META: Record = { 'Send /newbot and follow the instructions', 'Copy the bot token provided', 'Paste the token below', + 'Get your User ID from @userinfobot and paste it below', ], }, discord: {