Skip to main content
Glama

WhatsApp Web MCP

by pnizer
whatsapp-service.ts19.3 kB
import { Client, Contact, GroupChat, GroupParticipant, MessageMedia } from 'whatsapp-web.js'; // @ts-expect-error - ImportType not exported in whatsapp-web.js but needed for GroupChat functionality import _GroupChat from 'whatsapp-web.js/src/structures/GroupChat'; import { StatusResponse, ContactResponse, ChatResponse, MessageResponse, SendMessageResponse, GroupResponse, CreateGroupResponse, AddParticipantsResponse, MediaResponse, SendMediaMessageParams, SendMediaMessageResponse, } from './types'; import logger from './logger'; import path from 'path'; import fs from 'fs'; import mime from 'mime-types'; export function timestampToIso(timestamp: number): string { return new Date(timestamp * 1000).toISOString(); } export class WhatsAppService { private client: Client; constructor(client: Client) { this.client = client; } async getStatus(): Promise<StatusResponse> { try { const status = this.client.info ? 'connected' : 'disconnected'; return { status, info: this.client.info, }; } catch (error) { throw new Error( `Failed to get client status: ${error instanceof Error ? error.message : String(error)}`, ); } } async getContacts(): Promise<ContactResponse[]> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } const contacts = await this.client.getContacts(); const filteredContacts = contacts.filter( (contact: Contact) => contact.isUser && contact.id.server === 'c.us' && !contact.isMe, ); return filteredContacts.map((contact: Contact) => ({ name: contact.pushname || 'Unknown', number: contact.number, })); } catch (error) { throw new Error( `Failed to fetch contacts: ${error instanceof Error ? error.message : String(error)}`, ); } } async searchContacts(query: string): Promise<ContactResponse[]> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } const contacts = await this.client.getContacts(); const filteredContacts = contacts.filter( (contact: Contact) => contact.isUser && contact.id.server === 'c.us' && !contact.isMe && ((contact.pushname && contact.pushname.toLowerCase().includes(query.toLowerCase())) || (contact.number && contact.number.includes(query))), ); return filteredContacts.map((contact: Contact) => ({ name: contact.pushname || 'Unknown', number: contact.number, })); } catch (error) { throw new Error( `Failed to search contacts: ${error instanceof Error ? error.message : String(error)}`, ); } } async getChats(): Promise<ChatResponse[]> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } const chats = await this.client.getChats(); return chats.map(chat => { const lastMessageTimestamp = chat.lastMessage ? timestampToIso(chat.lastMessage.timestamp) : ''; return { id: chat.id._serialized, name: chat.name, unreadCount: chat.unreadCount, timestamp: lastMessageTimestamp, lastMessage: chat.lastMessage ? chat.lastMessage.body : '', }; }); } catch (error) { throw new Error( `Failed to fetch chats: ${error instanceof Error ? error.message : String(error)}`, ); } } async getMessages(number: string, limit: number = 10): Promise<MessageResponse[]> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } // Ensure number is a string if (typeof number !== 'string' || number.trim() === '') { throw new Error('Invalid phone number'); } // Format the chat ID const chatId = number.includes('@c.us') ? number : `${number}@c.us`; // Get the chat const chat = await this.client.getChatById(chatId); const messages = await chat.fetchMessages({ limit }); return messages.map(message => ({ id: message.id._serialized, body: message.body, fromMe: message.fromMe, timestamp: timestampToIso(message.timestamp), contact: message.fromMe ? undefined : message.author?.split('@')[0], type: message.type, })); } catch (error) { throw new Error( `Failed to fetch messages: ${error instanceof Error ? error.message : String(error)}`, ); } } async sendMessage(number: string, message: string): Promise<SendMessageResponse> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } // Ensure number is a string if (typeof number !== 'string' || number.trim() === '') { throw new Error('Invalid phone number'); } // Format the chat ID const chatId = number.includes('@c.us') ? number : `${number}@c.us`; // Send the message const result = await this.client.sendMessage(chatId, message); return { messageId: result.id.id, }; } catch (error) { throw new Error( `Failed to send message: ${error instanceof Error ? error.message : String(error)}`, ); } } async createGroup(name: string, participants: string[]): Promise<CreateGroupResponse> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } if (typeof name !== 'string' || name.trim() === '') { throw new Error('Invalid group name'); } const formattedParticipants = participants.map(p => (p.includes('@c.us') ? p : `${p}@c.us`)); // Create the group const result = await this.client.createGroup(name, formattedParticipants); // Handle both string and object return types let groupId = ''; let inviteCode = undefined; if (typeof result === 'string') { groupId = result; } else if (result && typeof result === 'object') { // Safely access properties groupId = result.gid && result.gid._serialized ? result.gid._serialized : ''; inviteCode = (result as { inviteCode?: string }).inviteCode; } return { groupId, inviteCode, }; } catch (error) { throw new Error( `Failed to create group: ${error instanceof Error ? error.message : String(error)}`, ); } } async addParticipantsToGroup( groupId: string, participants: string[], ): Promise<AddParticipantsResponse> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } if (typeof groupId !== 'string' || groupId.trim() === '') { throw new Error('Invalid group ID'); } const formattedParticipants = participants.map(p => (p.includes('@c.us') ? p : `${p}@c.us`)); const chat = await this.getRawGroup(groupId); const results = (await chat.addParticipants(formattedParticipants)) as | Record<string, { code: number; message: string; isInviteV4Sent: boolean }> | string; const resultMap: Record<string, { code: number; message: string; isInviteV4Sent: boolean }> = {}; if (typeof results === 'object') { for (const [id, result] of Object.entries(results)) { resultMap[id] = result; } } else { // If the result is not an object, string is a error message throw new Error(results); } // Process results const added: string[] = []; const failed: { number: string; reason: string }[] = []; for (const [id, success] of Object.entries(resultMap)) { const number = id.split('@')[0]; if (success.code === 200) { added.push(number); } else { failed.push({ number, reason: success.message }); } } return { success: failed.length === 0, added, failed: failed.length > 0 ? failed : undefined, }; } catch (error) { throw new Error( `Failed to add participants to group: ${error instanceof Error ? error.message : String(error)}`, ); } } async getGroupMessages(groupId: string, limit: number = 10): Promise<MessageResponse[]> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } // Ensure groupId is valid if (typeof groupId !== 'string' || groupId.trim() === '') { throw new Error('Invalid group ID'); } // Format the group ID const formattedGroupId = groupId.includes('@g.us') ? groupId : `${groupId}@g.us`; // Get the chat const chat = await this.client.getChatById(formattedGroupId); const messages = await chat.fetchMessages({ limit }); return messages.map(message => ({ id: message.id._serialized, body: message.body, fromMe: message.fromMe, timestamp: timestampToIso(message.timestamp), contact: message.fromMe ? undefined : message.author?.split('@')[0], type: message.type, })); } catch (error) { throw new Error( `Failed to fetch group messages: ${error instanceof Error ? error.message : String(error)}`, ); } } async sendGroupMessage(groupId: string, message: string): Promise<SendMessageResponse> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } // Ensure groupId is valid if (typeof groupId !== 'string' || groupId.trim() === '') { throw new Error('Invalid group ID'); } // Format the group ID const formattedGroupId = groupId.includes('@g.us') ? groupId : `${groupId}@g.us`; // Send the message const result = await this.client.sendMessage(formattedGroupId, message); return { messageId: result.id.id, }; } catch (error) { throw new Error( `Failed to send group message: ${error instanceof Error ? error.message : String(error)}`, ); } } async getUserName(id: string): Promise<string | undefined> { const contact = await this.client.getContactById(id); return contact.pushname || contact.name || undefined; } async getGroups(): Promise<GroupResponse[]> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } // Get all chats // It's not possible to use getGroups because WhatsApp Client is not setting the isGroup property // @ts-expect-error - Using raw API to access methods not exposed in the Client type const rawChats = await this.client.pupPage.evaluate(async () => { // @ts-expect-error - Accessing window.WWebJS which is not typed but exists at runtime return await window.WWebJS.getChats(); }); const groupChats: GroupChat[] = rawChats .filter((chat: { groupMetadata?: unknown }) => chat.groupMetadata) .map((chat: { groupMetadata?: unknown; isGroup?: boolean }) => { chat.isGroup = true; return new _GroupChat(this.client, chat); }); logger.info(`Found ${groupChats.length} groups`); const groups: GroupResponse[] = await Promise.all( groupChats.map(async chat => ({ id: chat.id._serialized, name: chat.name, description: ((chat as { groupMetadata?: { subject?: string } }).groupMetadata || {}).subject || '', participants: await Promise.all( chat.participants.map(async participant => ({ id: participant.id._serialized, number: participant.id.user, isAdmin: participant.isAdmin, name: await this.getUserName(participant.id._serialized), })), ), createdAt: chat.timestamp ? timestampToIso(chat.timestamp) : new Date().toISOString(), })), ); return groups; } catch (error) { throw new Error( `Failed to fetch groups: ${error instanceof Error ? error.message : String(error)}`, ); } } async getGroupById(groupId: string): Promise<GroupResponse> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } // Ensure groupId is valid if (typeof groupId !== 'string' || groupId.trim() === '') { throw new Error('Invalid group ID'); } // It's not possible to use getChatById because WhatsApp Client is not setting the isGroup property const chat = await this.getRawGroup(groupId); return { id: chat.id._serialized, name: chat.name, description: ((chat as { groupMetadata?: { subject?: string } }).groupMetadata || {}).subject || '', participants: await Promise.all( chat.participants.map(async (participant: GroupParticipant) => ({ id: participant.id._serialized, number: participant.id.user, isAdmin: participant.isAdmin, name: await this.getUserName(participant.id._serialized), })), ), createdAt: chat.timestamp ? timestampToIso(chat.timestamp) : new Date().toISOString(), }; } catch (error) { throw new Error( `Failed to fetch groups: ${error instanceof Error ? error.message : String(error)}`, ); } } async searchGroups(query: string): Promise<GroupResponse[]> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } const allGroups = await this.getGroups(); const lowerQuery = query.toLowerCase(); const matchingGroups = allGroups.filter(group => { if (group.name.toLowerCase().includes(lowerQuery)) { return true; } if (group.description && group.description.toLowerCase().includes(lowerQuery)) { return true; } return false; }); return matchingGroups; } catch (error) { throw new Error( `Failed to search groups: ${error instanceof Error ? error.message : String(error)}`, ); } } private async getRawGroup(groupId: string): Promise<_GroupChat> { // Clean up the group ID if it doesn't have the suffix const formattedGroupId = groupId.endsWith('@g.us') ? groupId : `${groupId}@g.us`; // @ts-expect-error - Using raw API to access methods not exposed in the Client type const rawChat = await this.client.pupPage.evaluate(async chatId => { // @ts-expect-error - Accessing window.WWebJS which is not typed but exists at runtime return await window.WWebJS.getChat(chatId); }, formattedGroupId); // Check if it's a group chat if (!rawChat.groupMetadata) { throw new Error('The provided ID is not a group chat'); } return new _GroupChat(this.client, rawChat); } // Download media from a message async downloadMediaFromMessage( messageId: string, mediaStoragePath: string, ): Promise<MediaResponse> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } const message = await this.client.getMessageById(messageId); if (!message) { throw new Error(`Message with ID ${messageId} not found`); } if (!message.hasMedia) { throw new Error(`Message with ID ${messageId} does not contain media`); } const media = await message.downloadMedia(); if (!media) { throw new Error(`Failed to download media from message ${messageId}`); } // Generate a unique filename based on messageId const extension = mime.extension(media.mimetype) || 'bin'; const filename = `${messageId.replace(/[^a-zA-Z0-9]/g, '_')}.${extension}`; const relativePath = path.join(mediaStoragePath, filename); // Convert to absolute path const absolutePath = path.resolve(relativePath); // Write the media to a file asynchronously const buffer = Buffer.from(media.data, 'base64'); await fs.promises.writeFile(absolutePath, buffer); // Get file size asynchronously const stats = await fs.promises.stat(absolutePath); return { filePath: absolutePath, // Return the absolute path mimetype: media.mimetype, filename, filesize: stats.size, messageId, // This is already the serialized messageId as you fixed }; } catch (error) { logger.error('Failed to download media', { error }); throw new Error( `Failed to download media: ${error instanceof Error ? error.message : String(error)}`, ); } } async sendMediaMessage({ number, source, caption, }: SendMediaMessageParams): Promise<SendMediaMessageResponse> { try { if (!this.client.info) { throw new Error('WhatsApp client not ready. Please try again later.'); } // Validate number if (typeof number !== 'string' || number.trim() === '') { throw new Error('Invalid phone number'); } // Format the chat ID const chatId = number.includes('@c.us') ? number : `${number}@c.us`; // Create MessageMedia based on source URI scheme let media: MessageMedia; try { if (source.startsWith('http://') || source.startsWith('https://')) { // URL source media = await MessageMedia.fromUrl(source); } else if (source.startsWith('file://')) { // Local file source (remove file:// prefix) const filePath = source.replace(/^file:\/\//, ''); media = await MessageMedia.fromFilePath(filePath); } else { throw new Error( 'Invalid source format. URLs must use http:// or https:// prefixes (e.g., https://example.com/image.jpg), local files must use file:// prefix (e.g., file:///path/to/image.jpg)', ); } } catch (error) { throw new Error( `Failed to load media from ${source}: ${ error instanceof Error ? error.message : String(error) }`, ); } // Validate media type (ensure it's an image) if (!media.mimetype.startsWith('image/')) { throw new Error('Only image files are supported at this time'); } // Send the media message const messageOptions = caption ? { caption } : undefined; const result = await this.client.sendMessage(chatId, media, messageOptions); return { messageId: result.id.id, mediaInfo: { mimetype: media.mimetype, filename: media.filename || 'unknown', size: media.data.length, // Base64 length as approximate size }, }; } catch (error) { throw new Error( `Failed to send media message: ${error instanceof Error ? error.message : String(error)}`, ); } } }

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/pnizer/wweb-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server