feat(channels): implement WhatsApp QR login proxy (#29)
This commit is contained in:
committed by
GitHub
Unverified
parent
0cf4ad3a8c
commit
7a9fd7fc0f
@@ -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,7 +107,7 @@ 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
351
electron/utils/whatsapp-login.ts
Normal file
351
electron/utils/whatsapp-login.ts
Normal 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();
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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'];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user