Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions electron/main/ipc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -532,6 +532,7 @@ function registerOpenClawHandlers(): void {
// Save channel configuration
ipcMain.handle('channel:saveConfig', async (_, channelType: string, config: Record<string, unknown>) => {
try {
logger.info('channel:saveConfig', { channelType, keys: Object.keys(config || {}) });
saveChannelConfig(channelType, config);
return { success: true };
} catch (error) {
Expand Down Expand Up @@ -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) };
}
});
Expand All @@ -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) };
}
});
Expand All @@ -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);
}
});
Expand Down
70 changes: 70 additions & 0 deletions electron/utils/channel-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, ChannelConfigData>;
[key: string]: unknown;
}

export interface OpenClawConfig {
channels?: Record<string, ChannelConfigData>;
plugins?: PluginsConfig;
[key: string]: unknown;
}

Expand All @@ -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 {};
}
Expand All @@ -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;
}
Expand All @@ -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 = {};
}
Expand Down Expand Up @@ -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}`);
}

Expand Down Expand Up @@ -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 = {};
}
Expand Down Expand Up @@ -457,10 +515,16 @@ async function validateTelegramCredentials(
): Promise<CredentialValidationResult> {
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 } };
Expand Down Expand Up @@ -553,6 +617,12 @@ export async function validateChannelConfig(channelType: string): Promise<Valida
result.errors.push('Telegram: Bot token is required');
result.valid = false;
}
// Check allowed users (stored as allowFrom array)
const allowedUsers = telegramConfig?.allowFrom as string[] | undefined;
if (!allowedUsers || allowedUsers.length === 0) {
result.errors.push('Telegram: Allowed User IDs are required');
result.valid = false;
}
}

if (result.errors.length === 0 && result.warnings.length === 0) {
Expand Down
56 changes: 43 additions & 13 deletions electron/utils/whatsapp-login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,10 @@ const QRCodeModule = require(join(qrcodeTerminalPath, 'vendor', 'QRCode', 'index
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;
interface BaileysError extends Error {
output?: { statusCode?: number };
}
type BaileysSocket = ReturnType<typeof makeWASocket>;
type ConnectionState = {
connection: 'close' | 'open' | 'connecting';
lastDisconnect?: {
Expand Down Expand Up @@ -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<void> {
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
*/
Expand Down Expand Up @@ -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<string, unknown>;
try {
// Try to resolve pino from baileys context since it's a dependency of baileys
const baileysRequire = createRequire(join(baileysPath, 'package.json'));
Expand Down Expand Up @@ -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<typeof setTimeout> | 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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
30 changes: 24 additions & 6 deletions src/pages/Channels/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand All @@ -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();
Expand Down
7 changes: 4 additions & 3 deletions src/types/channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -129,18 +129,19 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
},
{
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: [
'Open Telegram and search for @BotFather',
'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: {
Expand Down