UNPKG

whatsapp-claude-gpt

Version:

WhatsApp-Claude-GPT is a WhatsApp chatbot that supports multiple AI providers for chat, optional image generation/editing, and voice (speech-to-text and text-to-speech). It’s built for natural, contextual conversations and can now also handle reminders an

608 lines (517 loc) β€’ 21.3 kB
import logger from '../logger'; import { Chat, Message } from "whatsapp-web.js"; import { Readable } from 'stream'; import { AIConfig, CONFIG } from '../config'; import { AIAnswer, AIRole } from "../interfaces/ai-interfaces"; export function getFormattedDate(date?: Date) { const now = date || new Date(); const year = now.getFullYear(); const month = (now.getMonth() + 1).toString().padStart(2, '0'); const day = now.getDate().toString().padStart(2, '0'); const hours = now.getHours().toString().padStart(2, '0'); const minutes = now.getMinutes().toString().padStart(2, '0'); const seconds = now.getSeconds().toString().padStart(2, '0'); const offsetMinutes = now.getTimezoneOffset(); const offsetSign = offsetMinutes > 0 ? '-' : '+'; const absOffsetMinutes = Math.abs(offsetMinutes); const offsetHours = Math.floor(absOffsetMinutes / 60).toString().padStart(2, '0'); const offsetMins = (absOffsetMinutes % 60).toString().padStart(2, '0'); const offsetString = `${offsetSign}${offsetHours}:${offsetMins}`; const weekdays = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; const weekdayShort = weekdays[now.getDay()]; return `${year}-${month}-${day}T${hours}:${minutes}:${seconds}${offsetString} (${weekdayShort})`; } export function logMessage(message: Message, chat: Chat) { const msgDate = new Date(message.timestamp * 1000); logger.info( `[ReceivedMessage] {msg:'${message.body}', author:${getAuthorId(message)}, isGroup:${chat.isGroup}, chatId:${chat.id._serialized}, grName:${chat.name}, date:'${getFormattedDate(msgDate)}'}` ); } export function includeName(bodyMessage: string, name: string): boolean { if (!name || !bodyMessage) return false; const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); const regex = new RegExp(`(^|\\s)${escaped}($|[!?.]|\\s|,\\s)`, 'i'); return regex.test(bodyMessage); } export function removeNonAlphanumeric(str: string): string { if (!str) return str; const regex = /[^a-zA-Z0-9]/g; const normalized = str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); return normalized.replace(regex, ''); } export function parseCommand(input: string): { command?: string, commandMessage?: string } { const match = input.match(/^-(\S+)\s*(.*)/); if (!match) { return {commandMessage: input}; } return {command: match[1].trim(), commandMessage: match[2].trim()}; } export async function getUserName(message: Message) { const contactInfo = await message.getContact(); const name = CONFIG.BotConfig.useContactNames? contactInfo.shortName || contactInfo.name || contactInfo.pushname || contactInfo.number : contactInfo.pushname || contactInfo.number; return removeNonAlphanumeric(name); } export function bufferToStream(buffer) { const stream = new Readable(); stream.push(buffer); stream.push(null); return stream; } export function getUnsupportedMessage(type: string, body?: string) { const bodyStr = body ? `, body:"${body}"` : ``; const typeStr = `type:"${type}"`; return `<Unsupported message: {${typeStr}${bodyStr}}>` } export function configValidation() { function validateProvider(type: string, provider: string, config: any, configObject: any) { if (!config.apiKey) { const apiKeyEnvVar = getApiKeyEnvVarName(provider); logger.warn(`WARNING: ${provider} API key is missing when using ${provider} as ${type} provider.`); logger.warn(`The ${type} functionality will be automatically disabled.`); // Disable the functionality when API key is missing if (type === 'Image') { AIConfig.ImageConfig.enabled = false; } else if (type === 'Speech') { AIConfig.SpeechConfig.enabled = false; } else if (type === 'Transcription') { AIConfig.TranscriptionConfig.enabled = false; } else if (type === 'Chat') { // For chat, we don't disable but exit since it's essential logger.error(`ERROR: ${provider} API key is required when using ${provider} as ${type} provider.`); logger.error(`Please set the ${apiKeyEnvVar} environment variable in your .env file.`); process.exit(1); } return false; } if (provider === 'CUSTOM') { if (!config.baseURL) { logger.error(`ERROR: CUSTOM_BASEURL is required when using CUSTOM as ${type} provider.`); logger.error(`Please set the CUSTOM_BASEURL environment variable in your .env file.`); process.exit(1); } if (!config.model) { const modelEnvVar = getModelEnvVarName('CUSTOM', type); logger.error(`ERROR: CUSTOM model configuration is required when using CUSTOM as ${type} provider.`); logger.error(`Please set the ${modelEnvVar} environment variable in your .env file.`); process.exit(1); } } return true; } function getApiKeyEnvVarName(provider: string): string { const envVarMapping = { 'OPENAI': 'OPENAI_API_KEY', 'CLAUDE': 'CLAUDE_API_KEY', 'QWEN': 'QWEN_API_KEY', 'DEEPSEEK': 'DEEPSEEK_API_KEY', 'ELEVENLABS': 'ELEVENLABS_API_KEY', 'DEEPINFRA': 'DEEPINFRA_API_KEY', 'CUSTOM': 'CUSTOM_API_KEY' }; return envVarMapping[provider] || `${provider}_API_KEY`; } function getModelEnvVarName(provider: string, type: string): string { const typeMapping = { 'Chat': 'COMPLETION_MODEL', 'Image': 'IMAGE_MODEL', 'Transcription': 'TRANSCRIPTION_MODEL', 'Speech': 'SPEECH_MODEL' }; if (provider === 'CUSTOM') { return 'CUSTOM_' + typeMapping[type]; } else { return `${provider}_${typeMapping[type]}`; } } // Validate chat provider (required) validateProvider('Chat', AIConfig.ChatConfig.provider, AIConfig.ChatConfig, AIConfig.ChatConfig); // Validate optional providers if (AIConfig.ImageConfig.enabled) { validateProvider('Image', AIConfig.ImageConfig.provider, AIConfig.ImageConfig, AIConfig); } // If transcription is enabled, validate it (or disable if API key is missing) if (AIConfig.TranscriptionConfig.enabled) { validateProvider('Transcription', AIConfig.TranscriptionConfig.provider, AIConfig.TranscriptionConfig, AIConfig); } // If speech is enabled, validate it (or disable if API key is missing) if (AIConfig.SpeechConfig.enabled) { validateProvider('Speech', AIConfig.SpeechConfig.provider, AIConfig.SpeechConfig, AIConfig); } // If both transcription or speech are disabled, disable voice messages entirely if (!AIConfig.TranscriptionConfig.enabled || !AIConfig.SpeechConfig.enabled) { AIConfig.TranscriptionConfig.enabled = false; AIConfig.SpeechConfig.enabled = false; logger.warn('WARNING: Voice message handling has been disabled because either transcription or speech service is missing an API key.'); } const { provider: chatProvider } = AIConfig.ChatConfig; if (!['OPENAI', 'CLAUDE', 'QWEN', 'DEEPSEEK', 'DEEPINFRA', 'CUSTOM'].includes(chatProvider)) { logger.error(`ERROR: Invalid CHAT_PROVIDER: ${chatProvider}`); logger.error(`Valid options are: OPENAI, CLAUDE, QWEN, DEEPSEEK, DEEPINFRA, CUSTOM`); logger.error(`Please set a valid CHAT_PROVIDER in your .env file.`); process.exit(1); } if (AIConfig.ImageConfig.enabled) { const { provider: imageProvider } = AIConfig.ImageConfig; if (!['OPENAI', 'DEEPINFRA'].includes(imageProvider)) { logger.error(`ERROR: Invalid IMAGE_PROVIDER: ${imageProvider}`); logger.error(`Valid options are: OPENAI, DEEPINFRA`); logger.error(`Please set a valid IMAGE_PROVIDER in your .env file.`); process.exit(1); } } if (AIConfig.TranscriptionConfig.enabled) { const { provider: transcriptionProvider } = AIConfig.TranscriptionConfig; if (!['OPENAI', 'DEEPINFRA'].includes(transcriptionProvider)) { logger.error(`ERROR: Invalid TRANSCRIPTION_PROVIDER: ${transcriptionProvider}`); logger.error(`Valid options are: OPENAI, DEEPINFRA`); logger.error(`Please set a valid TRANSCRIPTION_PROVIDER in your .env file.`); process.exit(1); } } if (AIConfig.SpeechConfig.enabled) { const { provider: speechProvider } = AIConfig.SpeechConfig; if (!['OPENAI', 'ELEVENLABS'].includes(speechProvider)) { logger.error(`ERROR: Invalid SPEECH_PROVIDER: ${speechProvider}`); logger.error(`Valid options are: OPENAI, ELEVENLABS`); logger.error(`Please set a valid SPEECH_PROVIDER in your .env file.`); process.exit(1); } } logger.info('Configuration validation successful.'); } export function extractAnswer(input: string, botName: string): AIAnswer { // Remove <think> tags if they exist const cleanedInput = input?.replace(/<think>[\s\S]*?<\/think>/g, '').trim(); if (!cleanedInput || typeof cleanedInput !== 'string') { return null; } // Helper to fix common JSON string issues const fixJsonString = (jsonStr: string): string => { let fixed = ''; let inString = false; let escapeNext = false; for (let i = 0; i < jsonStr.length; i++) { const char = jsonStr[i]; if (escapeNext) { fixed += char; escapeNext = false; continue; } if (char === '\\') { fixed += char; escapeNext = true; continue; } if (char === '"') { inString = !inString; fixed += char; continue; } if (inString) { // Escape problematic characters inside strings switch (char) { case '\n': fixed += '\\n'; break; case '\r': fixed += '\\r'; break; case '\t': fixed += '\\t'; break; case '\b': fixed += '\\b'; break; case '\f': fixed += '\\f'; break; default: fixed += char; } } else { fixed += char; } } return fixed; }; // Helper to safely unescape nested JSON strings (for DeepSeek style responses) const unescapeNestedJson = (str: string): any => { try { // Handle multiple levels of JSON string escaping let unescaped = str; let attempts = 0; const maxAttempts = 3; // Prevent infinite loops while (attempts < maxAttempts) { try { const temp = JSON.parse(unescaped); if (typeof temp === 'string' && temp !== unescaped) { unescaped = temp; attempts++; } else { return temp; // Successfully parsed object } } catch { break; } } return JSON.parse(unescaped); } catch { return null; } }; // Attempt 1: Direct JSON parsing try { const parsed = JSON.parse(fixJsonString(cleanedInput)); if (parsed?.message !== undefined) { return parsed; } } catch (e) { logger.debug(`[extractAnswer] Direct JSON parsing failed: ${e.message}`); } // Attempt 2: Handle nested structure (DeepSeek style) try { const parsed = JSON.parse(fixJsonString(cleanedInput)); // Check for nested structure like {content: {text: "escaped_json"}} or {content: "escaped_json"} if (parsed?.content) { const contentText = typeof parsed.content === 'string' ? parsed.content : parsed.content.text; if (typeof contentText === 'string') { const nestedResult = unescapeNestedJson(contentText); if (nestedResult?.message !== undefined) { logger.debug("[extractAnswer] Successfully parsed nested JSON structure"); return nestedResult; } } } } catch (e) { logger.debug(`[extractAnswer] Nested structure parsing failed: ${e.message}`); } // Attempt 3: Extract JSON from mixed content using regex const jsonMatches = cleanedInput.match(/\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}/g); if (jsonMatches) { for (const match of jsonMatches) { try { const parsed = JSON.parse(fixJsonString(match)); if (parsed?.message !== undefined) { logger.debug("[extractAnswer] Successfully parsed regex-extracted JSON"); return parsed; } } catch { continue; // Try next match } } } // Attempt 4: Look for escaped JSON patterns const escapedJsonMatch = cleanedInput.match(/"([^"]*(?:\\.[^"]*)*)"/); if (escapedJsonMatch?.[1]) { try { const nestedResult = unescapeNestedJson(`"${escapedJsonMatch[1]}"`); if (nestedResult?.message !== undefined) { logger.debug("[extractAnswer] Successfully parsed escaped JSON pattern"); return nestedResult; } } catch { // Continue to fallback } } // Fallback: Return as plain text logger.debug("[extractAnswer] All parsing attempts failed, returning as plain text"); return { message: cleanedInput, author: botName, type: 'text' }; } export function logConfigInfo() { logger.info('========== CONFIGURATION SUMMARY =========='); // Bot general information logger.info(`πŸ“ BOT CONFIGURATION:`); logger.info(`β€’ Bot name: ${CONFIG.BotConfig.botName}`); logger.info(`β€’ Response character limit: ${CONFIG.BotConfig.maxCharacters}`); logger.info(`β€’ Maximum messages considered: ${CONFIG.BotConfig.maxMsgsLimit}`); logger.info(`β€’ Maximum message age: ${CONFIG.BotConfig.maxHoursLimit} hours`); logger.info(`β€’ Maximum images processed: ${CONFIG.BotConfig.maxImages}`); logger.info(`β€’ Memory: ${CONFIG.BotConfig.memoriesEnabled}`); // Chat provider and model logger.info(`πŸ€– CHAT PROVIDER:`); logger.info(`β€’ Provider: ${AIConfig.ChatConfig.provider}`); logger.info(`β€’ Model: ${AIConfig.ChatConfig.model}`); if (AIConfig.ChatConfig.baseURL && AIConfig.ChatConfig.provider !== 'OPENAI' && AIConfig.ChatConfig.provider !== 'CLAUDE') { logger.info(`β€’ Base URL: ${AIConfig.ChatConfig.baseURL}`); } logger.info(`β€’ Image analysis: ${AIConfig.ChatConfig.analyzeImageDisabled ? 'Disabled' : 'Enabled'}`); // Image configuration logger.info(`πŸ–ΌοΈ IMAGE GENERATION:`); if (AIConfig.ImageConfig.enabled) { logger.info(`β€’ Status: Enabled`); logger.info(`β€’ Provider: ${AIConfig.ImageConfig.provider}`); logger.info(`β€’ Model: ${AIConfig.ImageConfig.model}`); if (AIConfig.ImageConfig.baseURL && AIConfig.ImageConfig.provider !== 'OPENAI') { logger.info(`β€’ Base URL: ${AIConfig.ImageConfig.baseURL}`); } } else { logger.info(`β€’ Status: Disabled`); } // Voice message handling logger.info(`🎀 VOICE MESSAGE HANDLING:`); if (AIConfig.TranscriptionConfig.enabled && AIConfig.SpeechConfig.enabled) { logger.info(`β€’ Status: Enabled`); // Transcription (Speech-to-Text) logger.info(`TRANSCRIPTION (Speech-to-Text):`); logger.info(` β€’ Provider: ${AIConfig.TranscriptionConfig.provider}`); logger.info(` β€’ Model: ${AIConfig.TranscriptionConfig.model}`); logger.info(` β€’ Language: ${CONFIG.BotConfig.transcriptionLanguage}`); if (AIConfig.TranscriptionConfig.baseURL && AIConfig.TranscriptionConfig.provider !== 'OPENAI') { logger.info(` β€’ Base URL: ${AIConfig.TranscriptionConfig.baseURL}`); } // Speech (Text-to-Speech) logger.info(` SPEECH (Text-to-Speech):`); logger.info(` β€’ Provider: ${AIConfig.SpeechConfig.provider}`); logger.info(` β€’ Model: ${AIConfig.SpeechConfig.model}`); logger.info(` β€’ Voice: ${AIConfig.SpeechConfig.voice}`); if (AIConfig.SpeechConfig.baseURL && AIConfig.SpeechConfig.provider !== 'OPENAI' && AIConfig.SpeechConfig.provider !== 'ELEVENLABS') { logger.info(` β€’ Base URL: ${AIConfig.SpeechConfig.baseURL}`); } } else { logger.info(`β€’ Status: Disabled`); } // Additional information if preferred language is set if (CONFIG.BotConfig.preferredLanguage) { logger.info(`🌐 LANGUAGE PREFERENCES:`); logger.info(`β€’ Preferred language: ${CONFIG.BotConfig.preferredLanguage}`); } logger.info('==========================================='); } export function safeJsonToObject(value: any): any { if (!value || value === null) return null; if (typeof value === 'object') return value; try { return JSON.parse(value); } catch { return null; } } export function sanitizeForLog(value: any): any { if (value === null || value === undefined) return value; // Error objects: only safe properties, never config/headers if (value instanceof Error) { const result: any = { message: value.message, name: value.name, }; if (value.stack) result.stack = sanitizeForLog(value.stack); if ((value as any).isAxiosError) result.isAxiosError = true; if ((value as any).code) result.code = (value as any).code; if ((value as any).status) result.status = (value as any).status; return result; } // Strings: redact sensitive patterns, truncate if (typeof value === 'string') { let s = value .replace(/Bearer\s+[A-Za-z0-9\-._~+/=]+/gi, 'Bearer ***REDACTED***') .replace(/Authorization:\s*[A-Za-z0-9\-._~+/=]+/gi, 'Authorization: ***REDACTED***') .replace(/xi-api-key[=:]\s*[A-Za-z0-9\-._~+/=]+/gi, 'xi-api-key=***REDACTED***') .replace(/api[_-]?key[=:]\s*[A-Za-z0-9\-._~+/=]+/gi, 'api-key=***REDACTED***') .replace(/sk-[A-Za-z0-9\-._~]+/g, 'sk-***REDACTED***') .replace(/(data:image\/[a-zA-Z0-9+.-]+;base64,)[A-Za-z0-9+/=]{20,}/g, '$1***REDACTED***') .replace(/\+?\d{7,15}/g, '***PHONE***'); return s.length > 500 ? s.substring(0, 500) + '...' : s; } // Arrays: limit to 20 elements if (Array.isArray(value)) { return value.slice(0, 20).map(sanitizeForLog); } // Objects: redact sensitive keys recursively if (typeof value === 'object') { const sensitiveKeys = ['apiKey', 'secret', 'token', 'authorization', 'credential', 'password', 'key']; const result: any = {}; for (const [k, v] of Object.entries(value)) { const lowerKey = k.toLowerCase(); if (sensitiveKeys.some(sk => lowerKey.includes(sk))) { result[k] = '***REDACTED***'; } else { result[k] = sanitizeForLog(v); } } return result; } return value; } export function sanitizeLogImages(str: string) { return str.replace(/(data:image\/[a-zA-Z0-9+.-]+;base64,)[A-Za-z0-9+/=]+/g, '$1...'); } export function parseIfJson(input: any) { if (typeof input === 'object' && input !== null) { return input; } if (typeof input === 'string') { try { const parsed = JSON.parse(input); if (typeof parsed === 'object' && parsed !== null) { return parsed; } } catch (e) { return null; } } return null; } export function getAuthorId(wspMsg: Message): string{ return wspMsg.author || wspMsg.id?.remote || (wspMsg.id as any)?.participant; } export function addSeconds(date: Date, seconds: number): Date { const result = new Date(date); result.setSeconds(result.getSeconds() + seconds); return result; } export function convertCompletionsToolsToResponses(tools) { if (!Array.isArray(tools)) { throw new TypeError("tools must be an array"); } return tools.map((tool, idx) => { if (!tool || typeof tool !== "object") return tool; if (tool.type !== "function") return tool; if (!tool.function && tool.name && tool.parameters) { return tool; } const fn = tool.function || {}; const out = { type: "function", name: fn.name ?? tool.name, description: fn.description ?? tool.description, parameters: fn.parameters ?? tool.parameters } as any; if (typeof fn.strict !== "undefined") out.strict = fn.strict; else if (typeof tool.strict !== "undefined") out.strict = tool.strict; for (const k in tool) { if (["type", "function", "name", "description", "parameters", "strict"].includes(k)) continue; if (typeof out[k] === "undefined") out[k] = tool[k]; } for (const k in fn) { if (["name", "description", "parameters", "strict"].includes(k)) continue; if (typeof out[k] === "undefined") out[k] = fn[k]; } if (!out.name) { logger.warn(`Tool at index ${idx} is missing a function name after conversion.`); } if (!out.parameters) { logger.warn(`Tool "${out.name || idx}" is missing parameters schema after conversion.`); } return out; }); } export function sleep(ms: number): Promise<void> { return new Promise(resolve => setTimeout(resolve, ms)); } export function trimCachePreserveMessageStart(messages: any[], maxItems: number): any[] { if (!Array.isArray(messages)) return messages; if (countMessages(messages) > maxItems) { messages.splice(0, messages.length - maxItems); } else return messages; while (messages.length > 0 && messages[0].role != AIRole.USER && messages[0].role != AIRole.SYSTEM) { messages.shift(); } return messages; } export function cleanChatCompletionMessage(aiResponse: any){ for (let key in aiResponse) { if (aiResponse[key] === null || (Array.isArray(aiResponse[key]) && aiResponse[key].length === 0)) { delete aiResponse[key]; } } return aiResponse; } export function countMessages(aiMessageList: any): number { if(!aiMessageList || aiMessageList.length === 0) return 0; return aiMessageList.filter((i: any) => i.role === AIRole.USER || i.role === AIRole.SYSTEM || i.role === AIRole.ASSISTANT).length; }