mcp-whatsapp-web

MIT License
2
  • Apple
  • Linux
// Import the CommonJS module import { createRequire } from 'module'; const require = createRequire(import.meta.url); const { Client, LocalAuth, MessageMedia, // Message, Contact, Chat, ClientOptions - Not directly used, accessed via WAWebJS namespace // GroupChat // Import if needed later } = require('whatsapp-web.js'); // Import types from the module import type WAWebJS from 'whatsapp-web.js'; import { log } from '../utils/logger.js'; import path from 'path'; import { BrowserProcessManager } from '../utils/browser-process-manager.js'; // Define custom types or interfaces if needed, mapping from whatsapp-web.js types // For now, we'll use whatsapp-web.js types directly where possible, // but map them to simpler structures for MCP tools if necessary. export interface SimpleContact { id: string; // JID name: string | null; pushname: string; isMe: boolean; isUser: boolean; isGroup: boolean; isWAContact: boolean; isMyContact: boolean; number: string; } export interface SimpleChat { id: string; // JID name: string; isGroup: boolean; lastMessage?: SimpleMessage; // Optional: Include last message details unreadCount: number; timestamp: number; } export interface SimpleMessage { id: string; body: string; from: string; // Sender JID to: string; // Receiver JID (chat JID) timestamp: number; fromMe: boolean; hasMedia: boolean; mediaKey?: string; type: string; // e.g., 'chat', 'image', 'video', 'ptt' // Add more fields as needed } export class WhatsAppService { private client: WAWebJS.Client; private isInitialized = false; private latestQrCode: string | null = null; // Added to store QR code private browserProcessManager: BrowserProcessManager; // private dbService?: any; // Placeholder for optional DB service - Commented out constructor(/* dbService?: any */ /* Replace 'any' with actual DB service type */) { // this.dbService = dbService; // Commented out this.browserProcessManager = new BrowserProcessManager(); const clientOptions: WAWebJS.ClientOptions = { authStrategy: new LocalAuth({ dataPath: path.join(process.cwd(), 'whatsapp-sessions'), // Store sessions in project root }), puppeteer: { headless: true, // Run headless args: [ '--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage', '--disable-accelerated-2d-canvas', '--no-first-run', '--no-zygote', // '--single-process', // Might be needed on some systems '--disable-gpu', ], // Use Chrome executable path from environment variable if available // This is needed for video/gif sending as Chromium (default) doesn't support H.264/AAC codecs ...(process.env.CHROME_EXECUTABLE_PATH && { executablePath: process.env.CHROME_EXECUTABLE_PATH }) }, // qrTimeout option removed as it's not valid in whatsapp-web.js v1.23+ }; this.client = new Client(clientOptions); this.setupEventHandlers(); } private setupEventHandlers(): void { this.client.on('qr', (qr: string) => { log.info('QR code received.'); this.latestQrCode = qr; // Store the QR code // qrcodeTerminal.generate(qr, { small: true }); // Removed console logging }); this.client.on('authenticated', () => { // No type needed for msg here log.info('WhatsApp client authenticated.'); this.latestQrCode = null; // Clear QR code once authenticated }); this.client.on('auth_failure', (msg: string) => { // Add type string log.error('WhatsApp authentication failure:', msg); // Potentially exit or attempt re-authentication }); this.client.on('ready', () => { log.info('WhatsApp client is ready.'); this.isInitialized = true; // Perform actions after client is ready, e.g., fetch initial chats/contacts }); this.client.on('message', async (message: WAWebJS.Message) => { log.debug('Received message:', JSON.stringify(message)); // Handle incoming messages - potentially store in DB if dbService is configured // if (this.dbService) { // await this.dbService.storeMessage(this.mapMessageToSimpleMessage(message)); // } }); this.client.on('message_create', async (message: WAWebJS.Message) => { // Fired on all message creations, including your own if (message.fromMe) { log.debug('Sent message:', JSON.stringify(message)); // Handle outgoing messages - potentially store in DB // if (this.dbService) { // await this.dbService.storeMessage(this.mapMessageToSimpleMessage(message)); // } } }); this.client.on('disconnected', (reason: any) => { // Use any for reason for now log.warn('WhatsApp client disconnected:', reason); this.isInitialized = false; // Handle disconnection, maybe attempt to reconnect // this.initialize().catch(err => log.error('Reconnection failed:', err)); this.latestQrCode = null; // Clear QR on disconnect }); this.client.on('loading_screen', (percent: number, message: string) => { // Add types log.info(`WhatsApp loading: ${percent}% - ${message}`); }); } async initialize(): Promise<void> { if (this.isInitialized) { log.warn('WhatsApp client already initialized.'); return; } // Clean up any orphaned browser processes before starting await this.browserProcessManager.cleanupOrphanedProcesses(); log.info('Initializing WhatsApp client...'); try { await this.client.initialize(); // Register the browser process const pid = await this.getBrowserPid(); if (pid) { this.browserProcessManager.registerProcess(pid); log.info(`Registered browser process with PID: ${pid}`); } else { log.warn('Could not determine browser PID after initialization'); } } catch (error) { log.error('Error initializing WhatsApp client:', error); throw error; } } async destroy(): Promise<void> { log.info('Destroying WhatsApp client...'); try { // Get the PID before destroying the client const pid = await this.getBrowserPid(); // Ensure the client is properly destroyed to clean up the Puppeteer browser await this.client.destroy(); this.isInitialized = false; this.latestQrCode = null; log.info('WhatsApp client destroyed successfully'); // Unregister the browser process if (pid) { this.browserProcessManager.unregisterProcess(pid); log.info(`Unregistered browser process with PID: ${pid}`); } // Force garbage collection if possible to ensure browser process is released if (global.gc) { log.debug('Forcing garbage collection...'); global.gc(); } } catch (error) { log.error('Error destroying WhatsApp client:', error); throw error; } } async logout(): Promise<void> { log.info('Logging out of WhatsApp...'); try { // Get the PID before logging out const pid = await this.getBrowserPid(); // Logout from WhatsApp await this.client.logout(); this.isInitialized = false; this.latestQrCode = null; log.info('Successfully logged out of WhatsApp'); // Unregister the browser process if (pid) { this.browserProcessManager.unregisterProcess(pid); log.info(`Unregistered browser process with PID: ${pid}`); } } catch (error) { log.error('Error logging out of WhatsApp:', error); throw error; } } getClient(): WAWebJS.Client { if (!this.isInitialized) { // It might be better to wait for initialization or throw a more specific error log.warn('Accessing WhatsApp client before it is fully initialized.'); } return this.client; } getLatestQrCode(): string | null { return this.latestQrCode; } isAuthenticated(): boolean { // Check if the client is authenticated and connected // isInitialized means the client is ready and authenticated return this.isInitialized; } // --- Wrapper Methods for WhatsApp Functionality --- // Note: WWebContact and WWebChat aliases are removed from imports, use Contact and Chat directly async searchContacts(query: string): Promise<SimpleContact[]> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); const contacts = await this.client.getContacts(); const lowerQuery = query.toLowerCase(); return contacts .filter( (contact) => (contact.name?.toLowerCase().includes(lowerQuery) || contact.number.includes(query) || // Phone numbers usually don't need lowercasing contact.pushname?.toLowerCase().includes(lowerQuery)) && contact.isUser // Filter out groups/broadcasts if needed ) .map(this.mapContactToSimpleContact); } async listChats(limit = 20, includeLastMessage = true): Promise<SimpleChat[]> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); const chats = await this.client.getChats(); // Sort by timestamp descending (most recent first) chats.sort((a, b) => b.timestamp - a.timestamp); const limitedChats = chats.slice(0, limit); const simpleChats: SimpleChat[] = []; for (const chat of limitedChats) { let lastMsg: SimpleMessage | undefined = undefined; if (includeLastMessage && chat.lastMessage) { // Fetch the full last message object if needed, or use the partial info // For simplicity, we might just use the available info or fetch it // const fullLastMessage = await this.client.getMessageById(chat.lastMessage.id._serialized); // if (fullLastMessage) { // lastMsg = this.mapMessageToSimpleMessage(fullLastMessage); // } // Or map the partial info directly if sufficient lastMsg = { id: chat.lastMessage.id._serialized, body: chat.lastMessage.body, from: chat.lastMessage.from, to: chat.lastMessage.to, timestamp: chat.lastMessage.timestamp, fromMe: chat.lastMessage.fromMe, hasMedia: chat.lastMessage.hasMedia, type: chat.lastMessage.type, }; } simpleChats.push(this.mapChatToSimpleChat(chat, lastMsg)); } return simpleChats; } async getChatById(chatId: string): Promise<SimpleChat | null> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); try { const chat = await this.client.getChatById(chatId); return this.mapChatToSimpleChat(chat); } catch (error: any) { // Add type any log.warn(`Chat not found: ${chatId}`, error); return null; } } async getContactById(contactId: string): Promise<SimpleContact | null> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); try { const contact = await this.client.getContactById(contactId); return this.mapContactToSimpleContact(contact); } catch (error: any) { // Add type any log.warn(`Contact not found: ${contactId}`, error); return null; } } async getMessages(chatId: string, limit = 50): Promise<SimpleMessage[]> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); try { const chat = await this.client.getChatById(chatId); if (!chat) throw new Error(`Chat not found: ${chatId}`); const messages = await chat.fetchMessages({ limit }); return messages.map(this.mapMessageToSimpleMessage.bind(this)); } catch (error: any) { log.error(`Failed to get messages for chat ${chatId}:`, error); throw error; } } async getMessageById(messageId: string): Promise<SimpleMessage | null> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); try { const message = await this.client.getMessageById(messageId); return message ? this.mapMessageToSimpleMessage(message) : null; } catch (error: any) { // Add type any log.warn(`Failed to get message by ID ${messageId}:`, error); return null; } } async sendMessage(to: string, content: string): Promise<WAWebJS.Message> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); log.info(`Sending message to ${to}`); return this.client.sendMessage(to, content); } async sendMedia(to: string, mediaPathOrUrl: string, caption?: string): Promise<WAWebJS.Message> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); log.info(`Sending media from ${mediaPathOrUrl} to ${to}`); let media: WAWebJS.MessageMedia; if (mediaPathOrUrl.startsWith('http://') || mediaPathOrUrl.startsWith('https://')) { media = await MessageMedia.fromUrl(mediaPathOrUrl, { unsafeMime: true }); // unsafeMime might be needed for some URLs } else { media = MessageMedia.fromFilePath(mediaPathOrUrl); } return this.client.sendMessage(to, media, { caption }); } async sendMediaFromBase64(to: string, base64Data: string, mimeType: string, filename?: string, caption?: string): Promise<WAWebJS.Message> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); log.info(`Sending media from base64 to ${to}`); const media = new MessageMedia(mimeType, base64Data, filename); return this.client.sendMessage(to, media, { caption }); } async downloadMedia(messageId: string): Promise<WAWebJS.MessageMedia | null> { if (!this.isInitialized) throw new Error('WhatsApp client not ready'); try { const message = await this.client.getMessageById(messageId); if (message && message.hasMedia) { log.info(`Downloading media for message ${messageId}`); const media = await message.downloadMedia(); return media; } log.warn(`Message ${messageId} not found or has no media.`); return null; } catch (error: any) { // Add type any log.error(`Failed to download media for message ${messageId}:`, error); return null; } } // --- Helper Mappers --- private mapContactToSimpleContact(contact: WAWebJS.Contact): SimpleContact { return { id: contact.id._serialized, name: contact.name || null, pushname: contact.pushname, isMe: contact.isMe, isUser: contact.isUser, isGroup: contact.isGroup, isWAContact: contact.isWAContact, isMyContact: contact.isMyContact, number: contact.number, }; } private mapChatToSimpleChat(chat: WAWebJS.Chat, lastMessage?: SimpleMessage): SimpleChat { return { id: chat.id._serialized, name: chat.name, isGroup: chat.isGroup, lastMessage: lastMessage, unreadCount: chat.unreadCount, timestamp: chat.timestamp, }; } private mapMessageToSimpleMessage(message: WAWebJS.Message): SimpleMessage { return { id: message.id._serialized, body: message.body, from: message.from, to: message.to, timestamp: message.timestamp, fromMe: message.fromMe, hasMedia: message.hasMedia, mediaKey: message.mediaKey, type: message.type, // Add more fields as needed, e.g., ack status, quoted message info }; } /** * Get the process ID of the Chrome browser used by this WhatsApp client * @returns The browser PID or null if not available */ async getBrowserPid(): Promise<number | null> { try { if (!this.client) { return null; } // Access the internal puppeteer browser // This is a bit hacky but necessary to get the browser PID const client = this.client as any; // Try different ways to access the browser let browser = null; // Method 1: Try to access through pupBrowser property (if available) if (client.pupBrowser) { browser = client.pupBrowser; } // Method 2: Try to access through _page property else if (client._page && client._page.browser) { browser = client._page.browser(); } // Method 3: Try to access through puppeteer property else if (client.puppeteer && client.puppeteer.browser) { browser = client.puppeteer.browser; } if (browser) { const process = browser.process(); if (process) { return process.pid; } } log.warn('Could not access browser PID through any known method'); return null; } catch (error) { log.error('Error getting browser PID:', error); return null; } } }
ID: e9jribsiup