feat(channels): implement WhatsApp QR login proxy (#29)

This commit is contained in:
paisley
2026-02-10 14:06:53 +08:00
committed by GitHub
Unverified
parent 0cf4ad3a8c
commit 7a9fd7fc0f
8 changed files with 569 additions and 167 deletions

View File

@@ -7,7 +7,7 @@ import { GatewayManager, GatewayStatus } from './manager';
/** /**
* Channel types supported by OpenClaw * Channel types supported by OpenClaw
*/ */
export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'slack' | 'wechat'; export type ChannelType = 'whatsapp' | 'telegram' | 'discord' | 'wechat';
/** /**
* Channel status * Channel status
@@ -107,223 +107,223 @@ export interface ProviderConfig {
* Typed wrapper around GatewayManager for making RPC calls * Typed wrapper around GatewayManager for making RPC calls
*/ */
export class GatewayClient { export class GatewayClient {
constructor(private manager: GatewayManager) {} constructor(private manager: GatewayManager) { }
/** /**
* Get current gateway status * Get current gateway status
*/ */
getStatus(): GatewayStatus { getStatus(): GatewayStatus {
return this.manager.getStatus(); return this.manager.getStatus();
} }
/** /**
* Check if gateway is connected * Check if gateway is connected
*/ */
isConnected(): boolean { isConnected(): boolean {
return this.manager.isConnected(); return this.manager.isConnected();
} }
// ==================== Channel Methods ==================== // ==================== Channel Methods ====================
/** /**
* List all channels * List all channels
*/ */
async listChannels(): Promise<Channel[]> { async listChannels(): Promise<Channel[]> {
return this.manager.rpc<Channel[]>('channels.list'); return this.manager.rpc<Channel[]>('channels.list');
} }
/** /**
* Get channel by ID * Get channel by ID
*/ */
async getChannel(channelId: string): Promise<Channel> { async getChannel(channelId: string): Promise<Channel> {
return this.manager.rpc<Channel>('channels.get', { channelId }); return this.manager.rpc<Channel>('channels.get', { channelId });
} }
/** /**
* Connect a channel * Connect a channel
*/ */
async connectChannel(channelId: string): Promise<void> { async connectChannel(channelId: string): Promise<void> {
return this.manager.rpc<void>('channels.connect', { channelId }); return this.manager.rpc<void>('channels.connect', { channelId });
} }
/** /**
* Disconnect a channel * Disconnect a channel
*/ */
async disconnectChannel(channelId: string): Promise<void> { async disconnectChannel(channelId: string): Promise<void> {
return this.manager.rpc<void>('channels.disconnect', { channelId }); return this.manager.rpc<void>('channels.disconnect', { channelId });
} }
/** /**
* Get QR code for channel connection (e.g., WhatsApp) * Get QR code for channel connection (e.g., WhatsApp)
*/ */
async getChannelQRCode(channelType: ChannelType): Promise<string> { async getChannelQRCode(channelType: ChannelType): Promise<string> {
return this.manager.rpc<string>('channels.getQRCode', { channelType }); return this.manager.rpc<string>('channels.getQRCode', { channelType });
} }
// ==================== Skill Methods ==================== // ==================== Skill Methods ====================
/** /**
* List all skills * List all skills
*/ */
async listSkills(): Promise<Skill[]> { async listSkills(): Promise<Skill[]> {
return this.manager.rpc<Skill[]>('skills.list'); return this.manager.rpc<Skill[]>('skills.list');
} }
/** /**
* Enable a skill * Enable a skill
*/ */
async enableSkill(skillId: string): Promise<void> { async enableSkill(skillId: string): Promise<void> {
return this.manager.rpc<void>('skills.enable', { skillId }); return this.manager.rpc<void>('skills.enable', { skillId });
} }
/** /**
* Disable a skill * Disable a skill
*/ */
async disableSkill(skillId: string): Promise<void> { async disableSkill(skillId: string): Promise<void> {
return this.manager.rpc<void>('skills.disable', { skillId }); return this.manager.rpc<void>('skills.disable', { skillId });
} }
/** /**
* Get skill configuration * Get skill configuration
*/ */
async getSkillConfig(skillId: string): Promise<Record<string, unknown>> { async getSkillConfig(skillId: string): Promise<Record<string, unknown>> {
return this.manager.rpc<Record<string, unknown>>('skills.getConfig', { skillId }); return this.manager.rpc<Record<string, unknown>>('skills.getConfig', { skillId });
} }
/** /**
* Update skill configuration * Update skill configuration
*/ */
async updateSkillConfig(skillId: string, config: Record<string, unknown>): Promise<void> { async updateSkillConfig(skillId: string, config: Record<string, unknown>): Promise<void> {
return this.manager.rpc<void>('skills.updateConfig', { skillId, config }); return this.manager.rpc<void>('skills.updateConfig', { skillId, config });
} }
// ==================== Chat Methods ==================== // ==================== Chat Methods ====================
/** /**
* Send a chat message * Send a chat message
*/ */
async sendMessage(content: string, channelId?: string): Promise<ChatMessage> { async sendMessage(content: string, channelId?: string): Promise<ChatMessage> {
return this.manager.rpc<ChatMessage>('chat.send', { content, channelId }); return this.manager.rpc<ChatMessage>('chat.send', { content, channelId });
} }
/** /**
* Get chat history * Get chat history
*/ */
async getChatHistory(limit = 50, offset = 0): Promise<ChatMessage[]> { async getChatHistory(limit = 50, offset = 0): Promise<ChatMessage[]> {
return this.manager.rpc<ChatMessage[]>('chat.history', { limit, offset }); return this.manager.rpc<ChatMessage[]>('chat.history', { limit, offset });
} }
/** /**
* Clear chat history * Clear chat history
*/ */
async clearChatHistory(): Promise<void> { async clearChatHistory(): Promise<void> {
return this.manager.rpc<void>('chat.clear'); return this.manager.rpc<void>('chat.clear');
} }
// ==================== Cron Methods ==================== // ==================== Cron Methods ====================
/** /**
* List all cron tasks * List all cron tasks
*/ */
async listCronTasks(): Promise<CronTask[]> { async listCronTasks(): Promise<CronTask[]> {
return this.manager.rpc<CronTask[]>('cron.list'); return this.manager.rpc<CronTask[]>('cron.list');
} }
/** /**
* Create a new cron task * Create a new cron task
*/ */
async createCronTask(task: Omit<CronTask, 'id' | 'status'>): Promise<CronTask> { async createCronTask(task: Omit<CronTask, 'id' | 'status'>): Promise<CronTask> {
return this.manager.rpc<CronTask>('cron.create', task); return this.manager.rpc<CronTask>('cron.create', task);
} }
/** /**
* Update a cron task * Update a cron task
*/ */
async updateCronTask(taskId: string, updates: Partial<CronTask>): Promise<CronTask> { async updateCronTask(taskId: string, updates: Partial<CronTask>): Promise<CronTask> {
return this.manager.rpc<CronTask>('cron.update', { taskId, ...updates }); return this.manager.rpc<CronTask>('cron.update', { taskId, ...updates });
} }
/** /**
* Delete a cron task * Delete a cron task
*/ */
async deleteCronTask(taskId: string): Promise<void> { async deleteCronTask(taskId: string): Promise<void> {
return this.manager.rpc<void>('cron.delete', { taskId }); return this.manager.rpc<void>('cron.delete', { taskId });
} }
/** /**
* Run a cron task immediately * Run a cron task immediately
*/ */
async runCronTask(taskId: string): Promise<void> { async runCronTask(taskId: string): Promise<void> {
return this.manager.rpc<void>('cron.run', { taskId }); return this.manager.rpc<void>('cron.run', { taskId });
} }
// ==================== Provider Methods ==================== // ==================== Provider Methods ====================
/** /**
* List configured AI providers * List configured AI providers
*/ */
async listProviders(): Promise<ProviderConfig[]> { async listProviders(): Promise<ProviderConfig[]> {
return this.manager.rpc<ProviderConfig[]>('providers.list'); return this.manager.rpc<ProviderConfig[]>('providers.list');
} }
/** /**
* Add or update a provider * Add or update a provider
*/ */
async setProvider(provider: ProviderConfig): Promise<void> { async setProvider(provider: ProviderConfig): Promise<void> {
return this.manager.rpc<void>('providers.set', provider); return this.manager.rpc<void>('providers.set', provider);
} }
/** /**
* Remove a provider * Remove a provider
*/ */
async removeProvider(providerId: string): Promise<void> { async removeProvider(providerId: string): Promise<void> {
return this.manager.rpc<void>('providers.remove', { providerId }); return this.manager.rpc<void>('providers.remove', { providerId });
} }
/** /**
* Test provider connection * Test provider connection
*/ */
async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> { async testProvider(providerId: string): Promise<{ success: boolean; error?: string }> {
return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId }); return this.manager.rpc<{ success: boolean; error?: string }>('providers.test', { providerId });
} }
// ==================== System Methods ==================== // ==================== System Methods ====================
/** /**
* Get Gateway health status * Get Gateway health status
*/ */
async getHealth(): Promise<{ status: string; uptime: number; version?: string }> { async getHealth(): Promise<{ status: string; uptime: number; version?: string }> {
return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health'); return this.manager.rpc<{ status: string; uptime: number; version?: string }>('system.health');
} }
/** /**
* Get Gateway configuration * Get Gateway configuration
*/ */
async getConfig(): Promise<Record<string, unknown>> { async getConfig(): Promise<Record<string, unknown>> {
return this.manager.rpc<Record<string, unknown>>('system.config'); return this.manager.rpc<Record<string, unknown>>('system.config');
} }
/** /**
* Update Gateway configuration * Update Gateway configuration
*/ */
async updateConfig(config: Record<string, unknown>): Promise<void> { async updateConfig(config: Record<string, unknown>): Promise<void> {
return this.manager.rpc<void>('system.updateConfig', config); return this.manager.rpc<void>('system.updateConfig', config);
} }
/** /**
* Get Gateway version info * Get Gateway version info
*/ */
async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> { async getVersion(): Promise<{ version: string; nodeVersion?: string; platform?: string }> {
return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version'); return this.manager.rpc<{ version: string; nodeVersion?: string; platform?: string }>('system.version');
} }
/** /**
* Get available skill bundles * Get available skill bundles
*/ */
async getSkillBundles(): Promise<SkillBundle[]> { async getSkillBundles(): Promise<SkillBundle[]> {
return this.manager.rpc<SkillBundle[]>('skills.bundles'); return this.manager.rpc<SkillBundle[]>('skills.bundles');
} }
/** /**
* Install a skill bundle * Install a skill bundle
*/ */

View File

@@ -38,6 +38,7 @@ import {
} from '../utils/channel-config'; } from '../utils/channel-config';
import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup'; import { checkUvInstalled, installUv, setupManagedPython } from '../utils/uv-setup';
import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config'; import { updateSkillConfig, getSkillConfig, getAllSkillConfigs } from '../utils/skill-config';
import { whatsAppLoginManager } from '../utils/whatsapp-login';
/** /**
* Register all IPC handlers * Register all IPC handlers
@@ -82,6 +83,9 @@ export function registerIpcHandlers(
// Window control handlers (for custom title bar on Windows/Linux) // Window control handlers (for custom title bar on Windows/Linux)
registerWindowHandlers(mainWindow); registerWindowHandlers(mainWindow);
// WhatsApp handlers
registerWhatsAppHandlers(mainWindow);
} }
/** /**
@@ -614,6 +618,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 * Provider-related IPC handlers

View File

@@ -91,7 +91,11 @@ const electronAPI = {
'channel:listConfigured', 'channel:listConfigured',
'channel:setEnabled', 'channel:setEnabled',
'channel:validate', 'channel:validate',
'channel:validate',
'channel:validateCredentials', 'channel:validateCredentials',
// WhatsApp
'channel:requestWhatsAppQr',
'channel:cancelWhatsAppQr',
// ClawHub // ClawHub
'clawhub:search', 'clawhub:search',
'clawhub:install', 'clawhub:install',
@@ -134,6 +138,9 @@ const electronAPI = {
'gateway:notification', 'gateway:notification',
'gateway:channel-status', 'gateway:channel-status',
'gateway:chat-message', 'gateway:chat-message',
'channel:whatsapp-qr',
'channel:whatsapp-success',
'channel:whatsapp-error',
'gateway:exit', 'gateway:exit',
'gateway:error', 'gateway:error',
'navigate', 'navigate',

View File

@@ -2,7 +2,7 @@
* Channel Configuration Utilities * Channel Configuration Utilities
* Manages channel configuration in OpenClaw config files * 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 { join } from 'path';
import { homedir } from 'os'; import { homedir } from 'os';
@@ -201,6 +201,20 @@ export function deleteChannelConfig(channelType: string): void {
writeOpenClawConfig(currentConfig); writeOpenClawConfig(currentConfig);
console.log(`Deleted channel config for ${channelType}`); 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[] { export function listConfiguredChannels(): string[] {
const config = readOpenClawConfig(); const config = readOpenClawConfig();
if (!config.channels) { const channels: string[] = [];
return [];
if (config.channels) {
channels.push(...Object.keys(config.channels).filter(
(channelType) => config.channels![channelType]?.enabled !== false
));
} }
return Object.keys(config.channels).filter( // Check for WhatsApp credentials directory
(channelType) => config.channels![channelType]?.enabled !== false 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 channels;
} }
/** /**
@@ -266,8 +302,6 @@ export async function validateChannelCredentials(
return validateDiscordCredentials(config); return validateDiscordCredentials(config);
case 'telegram': case 'telegram':
return validateTelegramCredentials(config); return validateTelegramCredentials(config);
case 'slack':
return validateSlackCredentials(config);
default: default:
// For channels without specific validation, just check required fields are present // For channels without specific validation, just check required fields are present
return { valid: true, errors: [], warnings: ['No online validation available for this channel type.'] }; return { valid: true, errors: [], warnings: ['No online validation available for this channel type.'] };
@@ -424,58 +458,7 @@ async function validateTelegramCredentials(
} }
} }
/**
* Validate Slack bot token
*/
async function validateSlackCredentials(
config: Record<string, string>
): Promise<CredentialValidationResult> {
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<string, string> = {
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 * Validate channel configuration using OpenClaw doctor

View File

@@ -0,0 +1,351 @@
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<string> {
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<void> {
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<void> {
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 (err) {
console.error('[WhatsAppLogin] Failed to clear auth dir:', err);
}
}
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<void> {
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();

View File

@@ -369,6 +369,10 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
setConfigValues({}); setConfigValues({});
setChannelName(''); setChannelName('');
setIsExistingConfig(false); setIsExistingConfig(false);
setChannelName('');
setIsExistingConfig(false);
// Ensure we clean up any pending QR session if switching away
window.electron.ipcRenderer.invoke('channel:cancelWhatsAppQr').catch(() => { });
return; return;
} }
@@ -404,6 +408,47 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
return () => { cancelled = true; }; return () => { cancelled = true; };
}, [selectedType]); }, [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 () => { const handleValidate = async () => {
if (!selectedType) return; if (!selectedType) return;
@@ -457,10 +502,9 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
try { try {
// For QR-based channels, request QR code // For QR-based channels, request QR code
if (meta.connectionType === 'qr') { if (meta.connectionType === 'qr') {
// Simulate QR code generation (in real implementation, call Gateway) const accountId = channelName.trim() || 'default';
await new Promise((resolve) => setTimeout(resolve, 1500)); await window.electron.ipcRenderer.invoke('channel:requestWhatsAppQr', accountId);
setQrCode('placeholder-qr'); // The QR code will be set via event listener
setConnecting(false);
return; return;
} }
@@ -625,23 +669,24 @@ function AddChannelDialog({ selectedType, onSelectType, onClose, onChannelAdded
) : qrCode ? ( ) : qrCode ? (
// QR Code display // QR Code display
<div className="text-center space-y-4"> <div className="text-center space-y-4">
<div className="bg-white p-4 rounded-lg inline-block"> <div className="bg-white p-4 rounded-lg inline-block shadow-sm border">
<div className="w-48 h-48 bg-gray-100 flex items-center justify-center"> {qrCode.startsWith('data:image') ? (
<QrCode className="h-32 w-32 text-gray-400" /> <img src={qrCode} alt="Scan QR Code" className="w-64 h-64 object-contain" />
</div> ) : (
<div className="w-64 h-64 bg-gray-100 flex items-center justify-center">
<QrCode className="h-32 w-32 text-gray-400" />
</div>
)}
</div> </div>
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Scan this QR code with {meta?.name} to connect Scan this QR code with {meta?.name} to connect
</p> </p>
<div className="flex justify-center gap-2"> <div className="flex justify-center gap-2">
<Button variant="outline" onClick={() => setQrCode(null)}> <Button variant="outline" onClick={() => {
Generate New Code setQrCode(null);
</Button> handleConnect(); // Retry
<Button onClick={() => {
toast.success('Channel connected successfully');
onChannelAdded();
}}> }}>
I've Scanned It Refresh Code
</Button> </Button>
</div> </div>
</div> </div>

View File

@@ -28,10 +28,10 @@ export function Dashboard() {
const { channels, fetchChannels } = useChannelsStore(); const { channels, fetchChannels } = useChannelsStore();
const { skills, fetchSkills } = useSkillsStore(); const { skills, fetchSkills } = useSkillsStore();
const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked); const devModeUnlocked = useSettingsStore((state) => state.devModeUnlocked);
const isGatewayRunning = gatewayStatus.state === 'running'; const isGatewayRunning = gatewayStatus.state === 'running';
const [uptime, setUptime] = useState(0); const [uptime, setUptime] = useState(0);
// Fetch data only when gateway is running // Fetch data only when gateway is running
useEffect(() => { useEffect(() => {
if (isGatewayRunning) { if (isGatewayRunning) {
@@ -39,11 +39,11 @@ export function Dashboard() {
fetchSkills(); fetchSkills();
} }
}, [fetchChannels, fetchSkills, isGatewayRunning]); }, [fetchChannels, fetchSkills, isGatewayRunning]);
// Calculate statistics safely // Calculate statistics safely
const connectedChannels = Array.isArray(channels) ? channels.filter((c) => c.status === 'connected').length : 0; 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; const enabledSkills = Array.isArray(skills) ? skills.filter((s) => s.enabled).length : 0;
// Update uptime periodically // Update uptime periodically
useEffect(() => { useEffect(() => {
const updateUptime = () => { const updateUptime = () => {
@@ -53,13 +53,13 @@ export function Dashboard() {
setUptime(0); setUptime(0);
} }
}; };
// Update immediately // Update immediately
updateUptime(); updateUptime();
// Update every second // Update every second
const interval = setInterval(updateUptime, 1000); const interval = setInterval(updateUptime, 1000);
return () => clearInterval(interval); return () => clearInterval(interval);
}, [gatewayStatus.connectedAt]); }, [gatewayStatus.connectedAt]);
@@ -79,7 +79,7 @@ export function Dashboard() {
console.error('Error opening Dev Console:', err); console.error('Error opening Dev Console:', err);
} }
}; };
return ( return (
<div className="space-y-6"> <div className="space-y-6">
{/* Status Cards */} {/* Status Cards */}
@@ -101,7 +101,7 @@ export function Dashboard() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Channels */} {/* Channels */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -115,7 +115,7 @@ export function Dashboard() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{/* Skills */} {/* Skills */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -129,7 +129,7 @@ export function Dashboard() {
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
{/* Uptime */} {/* Uptime */}
<Card> <Card>
<CardHeader className="flex flex-row items-center justify-between pb-2"> <CardHeader className="flex flex-row items-center justify-between pb-2">
@@ -146,7 +146,7 @@ export function Dashboard() {
</CardContent> </CardContent>
</Card> </Card>
</div> </div>
{/* Quick Actions */} {/* Quick Actions */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -192,7 +192,7 @@ export function Dashboard() {
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
{/* Recent Activity */} {/* Recent Activity */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* Connected Channels */} {/* Connected Channels */}
@@ -221,7 +221,6 @@ export function Dashboard() {
{channel.type === 'whatsapp' && '📱'} {channel.type === 'whatsapp' && '📱'}
{channel.type === 'telegram' && '✈️'} {channel.type === 'telegram' && '✈️'}
{channel.type === 'discord' && '🎮'} {channel.type === 'discord' && '🎮'}
{channel.type === 'slack' && '💼'}
</span> </span>
<div> <div>
<p className="font-medium">{channel.name}</p> <p className="font-medium">{channel.name}</p>
@@ -237,7 +236,7 @@ export function Dashboard() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
{/* Enabled Skills */} {/* Enabled Skills */}
<Card> <Card>
<CardHeader> <CardHeader>
@@ -284,7 +283,7 @@ function formatUptime(seconds: number): string {
const days = Math.floor(seconds / 86400); const days = Math.floor(seconds / 86400);
const hours = Math.floor((seconds % 86400) / 3600); const hours = Math.floor((seconds % 86400) / 3600);
const minutes = Math.floor((seconds % 3600) / 60); const minutes = Math.floor((seconds % 3600) / 60);
if (days > 0) { if (days > 0) {
return `${days}d ${hours}h`; return `${days}d ${hours}h`;
} else if (hours > 0) { } else if (hours > 0) {

View File

@@ -10,7 +10,6 @@ export type ChannelType =
| 'whatsapp' | 'whatsapp'
| 'telegram' | 'telegram'
| 'discord' | 'discord'
| 'slack'
| 'signal' | 'signal'
| 'feishu' | 'feishu'
| 'imessage' | 'imessage'
@@ -81,7 +80,6 @@ export const CHANNEL_ICONS: Record<ChannelType, string> = {
whatsapp: '📱', whatsapp: '📱',
telegram: '✈️', telegram: '✈️',
discord: '🎮', discord: '🎮',
slack: '💼',
signal: '🔒', signal: '🔒',
feishu: '🐦', feishu: '🐦',
imessage: '💬', imessage: '💬',
@@ -99,7 +97,6 @@ export const CHANNEL_NAMES: Record<ChannelType, string> = {
whatsapp: 'WhatsApp', whatsapp: 'WhatsApp',
telegram: 'Telegram', telegram: 'Telegram',
discord: 'Discord', discord: 'Discord',
slack: 'Slack',
signal: 'Signal', signal: 'Signal',
feishu: 'Feishu / Lark', feishu: 'Feishu / Lark',
imessage: 'iMessage', imessage: 'iMessage',
@@ -180,51 +177,20 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
'Paste the bot token below', '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: { whatsapp: {
id: 'whatsapp', id: 'whatsapp',
name: 'WhatsApp', name: 'WhatsApp',
icon: '📱', icon: '📱',
description: 'Connect WhatsApp by scanning a QR code', description: 'Connect WhatsApp by scanning a QR code (no phone number required)',
connectionType: 'qr', connectionType: 'qr',
docsUrl: 'https://docs.openclaw.ai/channels/whatsapp', docsUrl: 'https://docs.openclaw.ai/channels/whatsapp',
configFields: [], configFields: [],
instructions: [ instructions: [
'Open WhatsApp on your phone', 'Open WhatsApp on your phone',
'Go to Settings > Linked Devices', 'Go to Settings > Linked Devices > Link a Device',
'Tap "Link a Device"',
'Scan the QR code shown below', 'Scan the QR code shown below',
'The system will automatically identify your phone number',
], ],
}, },
signal: { signal: {
@@ -465,7 +431,7 @@ export const CHANNEL_META: Record<ChannelType, ChannelMeta> = {
* Get primary supported channels (non-plugin, commonly used) * Get primary supported channels (non-plugin, commonly used)
*/ */
export function getPrimaryChannels(): ChannelType[] { export function getPrimaryChannels(): ChannelType[] {
return ['telegram', 'discord', 'slack', 'whatsapp', 'feishu']; return ['telegram', 'discord', 'whatsapp', 'feishu'];
} }
/** /**