UNPKG

whatsapp-claude-gpt

Version:

WhatsApp-Claude-GPT is an advanced chatbot for WhatsApp, integrating AI language models for text conversations, image generation, and voice messages.

686 lines (587 loc) 31.2 kB
import { OpenaiService } from './services/openai-service'; import { Chat, Client, GroupChat, Message, MessageMedia, MessageTypes } from 'whatsapp-web.js'; import { bufferToStream, extractAnswer, getContactName, getUnsupportedMessage, includeName, logMessage, parseCommand } from './utils'; import logger from './logger'; import { AIConfig, CONFIG } from './config'; import { AIAnswer, AIContent, AiMessage, AIProvider, AIRole } from './interfaces/ai-interfaces'; import { ChatCompletionMessageParam } from 'openai/resources'; import { AnthropicService } from './services/anthropic-service'; import { ImageBlockParam, MessageParam, TextBlock } from '@anthropic-ai/sdk/src/resources/messages'; import NodeCache from 'node-cache'; import { elevenTTS } from './services/elevenlabs-service'; import { chatConfigurationManager } from './config/chat-configurations'; import { ResponseInput } from "openai/resources/responses/responses"; import { AITools } from "./config/openai-functions"; export class Roboto { private openAIService: OpenaiService; private claudeService: AnthropicService; private botConfig = CONFIG.botConfig; private allowedTypes = [MessageTypes.STICKER, MessageTypes.TEXT, MessageTypes.IMAGE, MessageTypes.VOICE, MessageTypes.AUDIO]; private cache: NodeCache; private groupProcessingStatus: { [key: string]: boolean } = {}; public constructor() { this.openAIService = new OpenaiService(); this.claudeService = new AnthropicService(); this.cache = new NodeCache(); } /** * Handles incoming WhatsApp messages and decides the appropriate action. * * This function evaluates incoming messages to determine if a response is needed based on: * - Message type (text, image, audio, sticker) * - Group context (direct mentions, quoted replies) * - Command presence (prefixed with "-") * * Key functionalities: * - Processes commands with the commandSelect method * - Manages group processing with a queue system to prevent conflicts * - Handles AI response generation through processMessage * - Determines response format (text or audio) * - Logs messages and manages errors * * @param message - The incoming Message object from WhatsApp Web.js * @param client - The WhatsApp Web.js Client instance * @returns A promise resolving to a boolean indicating if a response was sent */ public async readMessage(message: Message, client: Client) { const chatData: Chat = await message.getChat(); try { // Extract the data input (extracts command e.g., "-a", and the message) const isAudioMsg = message.type == MessageTypes.VOICE || message.type == MessageTypes.AUDIO; const {command, commandMessage} = parseCommand(message.body); // If it's a "Broadcast" message, it's not processed if (chatData.id.user == 'status' || chatData.id._serialized == 'status@broadcast') return false; // Evaluates whether the message type will be processed if (!this.allowedTypes.includes(message.type) || (isAudioMsg && !AIConfig.SpeechConfig.enabled)) return false; const botName = chatData.isGroup ? chatConfigurationManager.getBotName(chatData.id._serialized) || this.botConfig.botName : this.botConfig.botName; // Evaluates if it should respond const isSelfMention = message.hasQuotedMsg ? (await message.getQuotedMessage()).fromMe : false; const isMentioned = includeName(message.body, botName); if (!isSelfMention && !isMentioned && !command && chatData.isGroup) return false; while (this.groupProcessingStatus[chatData.id._serialized]) { await new Promise(resolve => setTimeout(resolve, 2000)); } this.groupProcessingStatus[chatData.id._serialized] = true; // Logs the message logMessage(message, chatData); // Evaluates if it should go to the command flow if (!!command) { await chatData.sendStateTyping(); await this.commandSelect(message); await chatData.clearState(); return true; } // Sends message to ChatGPT chatData.sendStateTyping(); let chatResponseString: string = await this.processMessage(chatData); let chatResponse: AIAnswer = extractAnswer(chatResponseString, botName); if (!chatResponse) return; if(chatResponse.emojiReact) message.react(chatResponse.emojiReact); // Evaluate if response message must be Audio or Text if (chatResponse.type.toLowerCase() == 'audio' && AIConfig.SpeechConfig.enabled) { return this.speak(message, chatData, chatResponse.message, 'mp3'); } else { return this.returnResponse(message, chatResponse.message, chatData.isGroup, client); } } catch (e: any) { logger.error(e.message); return message.reply('Error 😔'); } finally { chatData.clearState(); this.groupProcessingStatus[chatData.id._serialized] = false; } } /** * Selects and executes an action based on the command in the message. * * This function acts as a command dispatcher that interprets commands prefixed with "-" * and routes them to the appropriate handler methods. Currently supports: * * - "-image [prompt]": Generates images using AI (if enabled in config) * - "-chatconfig [subcommand]": Manages chat-specific configurations * - "-reset": Resets the conversation context with the AI * * The function parses the command and its arguments from the message body * and executes the corresponding functionality. * * @param message - The Message object containing the command * @returns A promise resolving when the command processing is complete */ private async commandSelect(message: Message) { const {command, commandMessage} = parseCommand(message.body); switch (command) { case "image": if (!AIConfig.ImageConfig.enabled) return; return await this.createImage(message, commandMessage); case "chatconfig": return await this.handleChatConfigCommand(message, commandMessage!); case "reset": return await message.react('👍'); default: return true; } } /** * Processes an incoming message and generates an AI response based on chat context. * * This function is responsible for: * 1. Collecting recent chat messages to build conversation context * 2. Processing multiple content types (text, images, audio) * 3. Handling transcription of voice messages * 4. Applying chat-specific configurations (custom prompts, bot names) * 5. Selecting the appropriate AI provider and formatting messages accordingly * * The function limits context by time (messages older than maxHoursLimit are ignored) * and resets context if a "-reset" command is found in the chat history. * * @param chatData - The Chat object representing the conversation * @returns A promise that resolves with the AI-generated response or null if no response needed */ private async processMessage(chatData: Chat): Promise<string> { const actualDate = new Date(); const analyzeImages = !AIConfig.ChatConfig.analyzeImageDisabled; // Bot System prompt and name let systemPrompt = CONFIG.getSystemPrompt(); let botName = this.botConfig.botName; const chatConfiguration = chatConfigurationManager.getChatConfig(chatData.id._serialized); if (chatConfiguration) { botName = chatConfiguration.botName || this.botConfig.botName; systemPrompt = CONFIG.getSystemPrompt(chatConfiguration.promptInfo, botName); logger.debug(`Using custom configuration for ${chatConfiguration.isGroup ? 'group' : 'chat'} "${chatConfiguration.name}" with bot name "${botName}": ${systemPrompt}`); } // Initialize an array of messages const messageList: AiMessage[] = []; // Placeholder for promises for transcriptions - Image Counting let transcriptionPromises: { index: number, promise: Promise<string> }[] = []; let imageCount: number = 0; // Retrieve the last 'limit' number of messages to send them in order const fetchedMessages = await chatData.fetchMessages({limit: this.botConfig.maxMsgsLimit}); // Check for "-reset" command in chat history to potentially restart context const resetIndex = fetchedMessages.map(msg => msg.body).lastIndexOf("-reset"); const messagesToProcess = resetIndex >= 0 ? fetchedMessages.slice(resetIndex + 1) : fetchedMessages; for (const msg of messagesToProcess.reverse()) { try { // Validate if the message was written less than 24 (or maxHoursLimit) hours ago; if older, it's not considered const msgDate = new Date(msg.timestamp * 1000); if ((actualDate.getTime() - msgDate.getTime()) / (1000 * 60 * 60) > this.botConfig.maxHoursLimit) break; // Checks if a message already exists in the cache const cachedMessage = this.getCachedMessage(msg); // Check if the message includes media or if it is of another type const isImage = msg.type === MessageTypes.IMAGE || msg.type === MessageTypes.STICKER; const isAudio = msg.type === MessageTypes.VOICE || msg.type === MessageTypes.AUDIO; const isOther = !isImage && !isAudio && msg.type != 'chat'; // Limit the number of processed images to only the last few and ignore audio if cached const media = (isImage && imageCount < this.botConfig.maxImages && analyzeImages) || (isAudio && !cachedMessage) ? await msg.downloadMedia() : null; if (media && isImage) imageCount++; const role = (!msg.fromMe || isImage) ? AIRole.USER : AIRole.ASSISTANT; const name = msg.fromMe ? botName : (await getContactName(msg)); // Assemble the content as a mix of text and any included media const content: Array<AIContent> = []; if (isOther || (isAudio && !AIConfig.TranscriptionConfig.enabled)) content.push({type: 'text', value: getUnsupportedMessage(msg.type, msg.body)}); else if (isAudio && media && !cachedMessage) { transcriptionPromises.push({index: messageList.length, promise: this.transcribeVoice(media, msg)}); content.push({type: 'audio', value: '<Transcribing voice message...>'}); } if (isAudio && cachedMessage) content.push({type: 'audio', value: cachedMessage}); if (isImage && media) content.push({type: 'image', value: media.data, media_type: media.mimetype}); if (isImage && !media) content.push({type: 'text', value: '<Unprocessed image>'}); if (msg.body && !isOther) content.push({type: 'text', value: msg.body}); messageList.push({role: role, name: name, content: content}); } catch (e: any) { logger.error(`Error reading message - msg.type:${msg.type}; msg.body:${msg.body}. Error:${e.message}`); } } // If no new messages are present, return without action if (messageList.length == 0) return; // Wait for all transcriptions to complete const transcriptions = await Promise.all(transcriptionPromises.map(t => t.promise)); transcriptionPromises.forEach((transcriptionPromise, idx) => { const transcription = transcriptions[idx]; const messageIdx = transcriptionPromise.index; messageList[messageIdx].content = messageList[messageIdx].content.map(c => c.type === 'audio' && c.value === '<Transcribing voice message...>' ? {type: 'audio', value: transcription} : c ); }); // Send the message and return the text response if (AIConfig.ChatConfig.provider == AIProvider.CLAUDE) { const convertedMessageList: MessageParam[] = this.convertIaMessagesLang(messageList.reverse(), AIProvider.CLAUDE) as MessageParam[]; return await this.claudeService.sendChat(convertedMessageList, CONFIG.getSystemPrompt()); } else if (AIConfig.ChatConfig.provider == AIProvider.OPENAI) { const convertedMessageList: ResponseInput = this.convertIaMessagesLang(messageList.reverse(), AIConfig.ChatConfig.provider as AIProvider, systemPrompt) as ResponseInput; return await this.openAIService.sendChatWithTools(convertedMessageList, 'text', AITools); } else { const convertedMessageList: ChatCompletionMessageParam[] = this.convertIaMessagesLang(messageList.reverse(), AIConfig.ChatConfig.provider as AIProvider, systemPrompt) as ChatCompletionMessageParam[]; return await this.openAIService.sendCompletion(convertedMessageList); } } /** * Generates and sends an audio message by synthesizing speech from provided text. * * This function converts text to speech using either: * - OpenAI's text-to-speech API * - ElevenLabs' text-to-speech service * * If no explicit content is provided, it attempts to use the last message sent by the bot * as input for speech synthesis. The resulting audio is sent as a voice message. * * @param message - The Message object for reply context * @param chatData - The Chat object for the conversation * @param content - Optional text content to convert to speech * @param responseFormat - Optional format for the audio response * @returns A promise that resolves when the audio message is sent */ private async speak(message: Message, chatData: Chat, content: string | undefined, responseFormat?) { // Set the content to be spoken. If no content is explicitly provided, fetch the last bot reply for use. let messageToSay = content || await this.getLastBotMessage(chatData); try { // Generate speech audio from the given text content using the OpenAI API. let base64Audio; if (AIConfig.SpeechConfig.provider == AIProvider.ELEVENLABS) { base64Audio = await elevenTTS(messageToSay); } else { const audioBuffer = await this.openAIService.speech(messageToSay, responseFormat); base64Audio = audioBuffer.toString('base64'); } let audioMedia = new MessageMedia('audio/mp3', base64Audio, 'voice.mp3'); // Reply to the message with the synthesized speech audio. const repliedMsg = await message.reply(audioMedia, undefined, {sendAudioAsVoice: true}); this.cache.set(repliedMsg.id._serialized, messageToSay, CONFIG.botConfig.nodeCacheTime); } catch (e: any) { logger.error(`Error in speak function: ${e.message}`); throw e; } } /** * Creates and sends an AI-generated image in response to a text prompt. * * This function uses OpenAI's DALL-E model to generate an image based on * the provided text prompt. The resulting image is sent as a reply to the * original message. * * @param message - The Message object for reply context * @param content - The text prompt for image generation * @returns A promise that resolves when the image is sent */ private async createImage(message: Message, content: string | undefined) { // Verify that content is provided for image generation, return if not. if (!content) return; try { // Calls the ChatGPT service to generate an image based on the provided textual content. const imgResponse = await this.openAIService.createImage(content); let media; if (imgResponse[0].url) { media = await MessageMedia.fromUrl(imgResponse[0].url); } else if (imgResponse[0].b64_json) { media = new MessageMedia('image/png', imgResponse[0].b64_json); } // Reply to the message with the generated image. return await message.reply(media); } catch (e: any) { logger.error(`Error in createImage function: ${e.message}`); // In case of an error during image generation or sending the image, inform the user. return message.reply("I encountered a problem while trying to generate an image, please try again."); } } /** * Retrieves the last message sent by the bot in a chat. * * This function fetches recent messages from the chat history and * finds the most recent message sent by the bot (fromMe = true) * that contains substantive content. * * @param chatData - The Chat object to search for messages * @returns A promise resolving to the text of the last bot message */ private async getLastBotMessage(chatData: Chat) { const lastMessages = await chatData.fetchMessages({limit: 12}); let lastMessageBot: string = ''; for (const msg of lastMessages) { if (msg.fromMe && msg.body.length > 1) lastMessageBot = msg.body; } return lastMessageBot; } /** * Converts AI message structures between different language model formats. * * This function transforms message arrays into formats compatible with various AI providers: * - OpenAI: Structured with system, user and assistant roles * - Claude: Requires alternating user/assistant roles with specific content formats * - Qwen: Similar to OpenAI but with provider-specific adaptations * - DeepSeek: Uses JSON formatting with text blocks * - DeepInfra/Custom: Uses simplified message formatting * * The function handles text, audio transcriptions, and images appropriately for each provider. * * @param messageList - Array of AI messages to convert * @param lang - The target AI provider format * @param systemPrompt - Optional system prompt to include * @returns Formatted message array compatible with the specified AI provider */ private convertIaMessagesLang(messageList: AiMessage[], lang: AIProvider, systemPrompt?: string): MessageParam[] | ChatCompletionMessageParam[] | ResponseInput { switch (lang) { case AIProvider.CLAUDE: const claudeMessageList: MessageParam[] = []; let currentRole: AIRole = AIRole.USER; let gptContent: Array<TextBlock | ImageBlockParam> = []; messageList.forEach((msg, index) => { const role = msg.role === AIRole.ASSISTANT && msg.content.find(c => c.type === 'image') ? AIRole.USER : msg.role; if (role !== currentRole) { // Change role or if it's the last message if (gptContent.length > 0) { claudeMessageList.push({role: currentRole as any, content: gptContent}); gptContent = []; // Reset for the next block of messages } currentRole = role; // Ensure role alternation } // Add content to the current block msg.content.forEach(c => { if (['text', 'audio'].includes(c.type)) gptContent.push({ type: 'text', text: JSON.stringify({message: c.value, author: msg.name, type: c.type}) }); if (['image'].includes(c.type)) gptContent.push({ type: 'image', source: {data: c.value!, media_type: c.media_type as any, type: 'base64'} }); }); }); // Ensure the last block is not left out if (gptContent.length > 0) claudeMessageList.push({role: currentRole, content: gptContent}); // Ensure the first message is always AiRole.USER (by API requirement) if (claudeMessageList.length > 0 && claudeMessageList[0].role !== AIRole.USER) { claudeMessageList.shift(); // Remove the first element if it's not USER } return claudeMessageList; case AIProvider.DEEPSEEK: const deepSeekMsgList: any[] = []; messageList.forEach(msg => { if (msg.role == AIRole.ASSISTANT) { const textContent = msg.content.find(c => ['text', 'audio'].includes(c.type))!; const content = JSON.stringify({ type: 'text', text: JSON.stringify({message: textContent.value, author: msg.name, type: textContent.type, response_format: "json_object"}) }); deepSeekMsgList.push({content: content, name: msg.name!, role: msg.role}); } else { const gptContent: Array<any> = []; msg.content.forEach(c => { if (['image'].includes(c.type)) gptContent.push({ type: 'text', text: JSON.stringify({message: getUnsupportedMessage('image', ''), author: msg.name, type: c.type, response_format: "json_object"}) }); if (['text', 'audio'].includes(c.type)) gptContent.push({ type: 'text', text: JSON.stringify({message: c.value, author: msg.name, type: c.type, response_format: "json_object"}) }); }) deepSeekMsgList.push({content: gptContent, name: msg.name!, role: msg.role}); } }) deepSeekMsgList.unshift({role: AIRole.SYSTEM, content: [{type: 'text', text: systemPrompt}]}); return deepSeekMsgList; case AIProvider.OPENAI: const responseAPIMessageList: ResponseInput = []; messageList.forEach(msg => { const gptContent: Array<any> = []; msg.content.forEach(c => { const fromBot = msg.role == AIRole.ASSISTANT; if (['text', 'audio'].includes(c.type)) gptContent.push({ type: fromBot?'output_text':'input_text', text: JSON.stringify({message: c.value, author: msg.name, type: c.type, response_format:'json_object'}) }); if (['image'].includes(c.type)) gptContent.push({ type: 'input_image', image_url: `data:${c.media_type};base64,${c.value}`}); }) responseAPIMessageList.push({content: gptContent, role: msg.role}); }) responseAPIMessageList.unshift({role: AIRole.SYSTEM, content: systemPrompt}); return responseAPIMessageList; case AIProvider.QWEN: const chatgptMessageList: any[] = []; messageList.forEach(msg => { const gptContent: Array<any> = []; msg.content.forEach(c => { if (['text', 'audio'].includes(c.type)) gptContent.push({ type: 'text', text: JSON.stringify({message: c.value, author: msg.name, type: c.type, response_format: 'json_object'}) }); if (['image'].includes(c.type)) gptContent.push({type: 'image_url', image_url: {url: `data:${c.media_type};base64,${c.value}`}}); }) chatgptMessageList.push({content: gptContent, name: msg.name!, role: msg.role}); }) chatgptMessageList.unshift({role: AIRole.SYSTEM, content: [{type: 'text', text: systemPrompt}]}); return chatgptMessageList; case AIProvider.CUSTOM: case AIProvider.DEEPINFRA: const otherMsgList: any[] = []; messageList.forEach(msg => { if (msg.role == AIRole.ASSISTANT) { const textContent = msg.content.find(c => ['text', 'audio'].includes(c.type))!; const content = JSON.stringify({message: textContent.value, author: msg.name, type: textContent.type, response_format: "json_object"}); otherMsgList.push({content: content, name: msg.name!, role: msg.role}); } else { const gptContent: Array<any> = []; msg.content.forEach(c => { if (['image'].includes(c.type)) gptContent.push(JSON.stringify({message: getUnsupportedMessage('image', ''), author: msg.name, type: c.type, response_format: "json_object"})); if (['text', 'audio'].includes(c.type)) gptContent.push(JSON.stringify({message: c.value, author: msg.name, type: c.type, response_format: "json_object"})); }) otherMsgList.push({content: gptContent[0], role: msg.role}); } }) otherMsgList.unshift({role: AIRole.SYSTEM, content: systemPrompt}); return otherMsgList; default: return []; } } /** * Handles chat configuration commands for customizing bot behavior per chat. * * This function manages the following subcommands: * - prompt: Sets a custom personality/behavior for the bot in the current chat * - botname: Changes the bot's name for the current chat * - remove: Removes custom configurations and reverts to defaults * - show: Displays current custom configuration * * In group chats, only administrators can modify configurations. * * @param message - The Message object containing the command * @param commandText - The text of the command without the prefix * @returns A promise resolving to the sent reply message */ private async handleChatConfigCommand(message: Message, commandText: string) { const chat = await message.getChat(); const isGroup = chat.isGroup; // Solo verificar permisos de administrador en grupos if (isGroup) { const groupChat = chat as GroupChat; const participant = await groupChat.participants.find(p => p.id._serialized === message.author); const isAdmin = participant?.isAdmin || participant?.isSuperAdmin; if (!isAdmin) { return message.reply("Only group administrators can change the bot's configuration in groups."); } } const parts = commandText.split(' '); const subCommand = parts[0].toLowerCase(); switch (subCommand) { case 'prompt': case 'botname': const value = parts.slice(1).join(' '); if (!value) return message.reply(`Please provide a ${subCommand === 'prompt' ? 'prompt description' : 'name for the bot'}.`); const existingConfig = chatConfigurationManager.getChatConfig(chat.id._serialized); const updateOptions: any = { promptInfo: existingConfig?.promptInfo || CONFIG.botConfig.promptInfo, botName: existingConfig?.botName }; if (subCommand === 'prompt') updateOptions.promptInfo = value; else updateOptions.botName = value; const updatedConfig = chatConfigurationManager.updateChatConfig( chat.id._serialized, chat.name, isGroup, updateOptions ); return message.reply( subCommand === 'prompt' ? `✅ Updated prompt for this ${isGroup ? 'group' : 'chat'}. The bot now: ${updatedConfig.promptInfo}` : `✅ Bot name for this ${isGroup ? 'group' : 'chat'} has been set to: ${updatedConfig.botName}` ); case 'remove': const removed = chatConfigurationManager.removeChatConfig(chat.id._serialized); return message.reply( removed ? `✅ The custom prompt and bot name have been removed. The bot will use the default configuration.` : `This ${isGroup ? 'group' : 'chat'} did not have a custom configuration.` ); case 'show': const currentConfig = chatConfigurationManager.getChatConfig(chat.id._serialized); if (!currentConfig) return message.reply(`This ${isGroup ? 'group' : 'chat'} does not have a custom configuration.`); let response = currentConfig.promptInfo? `Current personality: ${currentConfig.promptInfo}`:``; if (currentConfig.botName) response += `\nBot name: ${currentConfig.botName ?? CONFIG.botConfig.botName}`; return message.reply(response); default: return message.reply( "Available commands:\n" + `- *-chatconfig prompt [description]*: Sets the bot's personality for this ${isGroup ? 'group' : 'chat'}\n` + `- *-chatconfig botname [name]*: Sets the bot's name for this ${isGroup ? 'group' : 'chat'}\n` + "- *-chatconfig remove*: Removes the custom configuration\n" + "- *-chatconfig show*: Displays the current configuration" ); } } /** * Transcribes a voice message to text using AI services. * * This function: * 1. Checks if the transcription already exists in cache * 2. Converts the media to an audio buffer and stream * 3. Sends the audio to the configured transcription service * 4. Caches the result for future use * * The function uses the OpenAI service which may route to different * providers based on configuration. * * @param media - The MessageMedia object containing the voice data * @param message - The Message object for cache identification * @returns A promise resolving to the transcribed text */ private async transcribeVoice(media: MessageMedia, message: Message): Promise<string> { try { // Check if the transcription exists in the cache const cachedMessage = this.getCachedMessage(message); if (cachedMessage) return cachedMessage; // Convert the base64 media data to a Buffer const audioBuffer = Buffer.from(media.data, 'base64'); // Convert the buffer to a stream const audioStream = bufferToStream(audioBuffer); logger.debug(`[${AIConfig.TranscriptionConfig.provider}->transcribeVoice] Starting audio transcription`); const transcribedText = await this.openAIService.transcription(audioStream); // Log the transcribed text logger.debug(`[${AIConfig.TranscriptionConfig.provider}->transcribeVoice] Transcribed text: ${transcribedText}`); // Store in cache this.cache.set(message.id._serialized, transcribedText, CONFIG.botConfig.nodeCacheTime); return transcribedText; } catch (error: any) { // Error handling logger.error(`Error transcribing voice message: ${error.message}`); return '<Error transcribing voice message>'; } } /** * Sends a response message appropriately based on chat context. * * This function handles the difference between group chats and direct messages: * - In groups: Replies to the original message, creating a thread * - In direct chats: Sends a new message to the chat * * @param message - The original Message object to reply to * @param responseMsg - The text content to send * @param isGroup - Boolean indicating if this is a group chat * @param client - The WhatsApp client instance * @returns A promise resolving to the sent message */ private returnResponse(message, responseMsg, isGroup, client) { if (isGroup) return message.reply(responseMsg); else return client.sendMessage(message.from, responseMsg); } /** * Retrieves a cached message by its unique identifier. * * This function checks the NodeCache instance for previously stored * message content, such as transcriptions or generated responses. * * @param msg - The Message object whose content might be cached * @returns The cached string content or undefined if not found */ private getCachedMessage(msg: Message) { return this.cache.get<string>(msg.id._serialized); } }