import { TextChannel, Attachment, Message } from 'discord.js' import { event, Events, UserMessage, clean, getServerConfig, getTextFileAttachmentData, getAttachmentData } from '../utils/index.js' import { redis } from '../client.js' import fs from 'fs/promises' import path from 'path' import { fileURLToPath } from 'url' import { Ollama } from 'ollama' import { Queue } from '../queues/queue.js' // Define interface for model response to improve type safety interface ModelResponse { status: 'success' | 'error' reply: string metadata?: { timestamp: string self_sentiment: number user_sentiment: { [userId: string]: number } redis_ops: Array<{ action: 'set' | 'get'; key: string; value?: number }> need_help: boolean } } // Define interface for user config interface UserConfig { options: { 'message-style': boolean 'switch-model': string 'modify-capacity': number 'message-stream'?: boolean } } // List of in-character error responses for unavoidable Discord replies const friendlyErrorResponses = [ 'Huh?', 'Sorry, I wasn’t listening. Can you say that again?', 'Um... what was that?', 'Oops, my mind wandered! Could you repeat that?', 'Hehe, I got distracted. Say it one more time?' ] // Function to get a random friendly error response const getFriendlyError = () => friendlyErrorResponses[Math.floor(Math.random() * friendlyErrorResponses.length)] /** * Max Message length for free users is 2000 characters (bot or not). * Bot supports infinite lengths for normal messages. * * @param message the message received from the channel */ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client, defaultModel }, message: Message) => { // Early check to prevent bot from replying to itself if (!client.user) { log('Client user is not defined. Skipping message processing.') return } const clientId = client.user.id if (message.author.id === clientId) { log(`Skipping message from self (bot ID: ${clientId}).`) return } let cleanedMessage = clean(message.content, clientId) log(`Message "${cleanedMessage}" from ${message.author.tag} in channel/thread ${message.channelId}.`) // Check if message is from a bot (not self), mentions the bot, or passes random chance const isBotMessage = message.author.bot && message.author.id !== clientId const isMentioned = message.mentions.has(clientId) const isCommand = message.content.startsWith('/') const randomChance = Math.random() < 0.01 // Reduced from 0.1 to 0.01 (1% chance) if (!isMentioned && !isBotMessage && (isCommand || !randomChance)) { log(`Skipping message: isMentioned=${isMentioned}, isBotMessage=${isBotMessage}, isCommand=${isCommand}, randomChance=${randomChance}`) return } // Check if message is a bot response to avoid loops const isBotResponseKey = `message:${message.id}:is_bot_response` if (isBotMessage) { try { const isBotResponse = await redis.get(isBotResponseKey) if (isBotResponse === 'true') { log(`Skipping bot message ${message.id} as it is a bot response.`) return } } catch (error) { log(`Failed to check isBotResponse in Redis: ${error}`) } } // Check for channel-wide bot-to-bot cooldown const channelCooldownKey = `channel:${message.channelId}:bot_cooldown` const cooldownPeriod = 300 // Increased from 60 to 300 seconds (5 minutes) if (isBotMessage) { log(`Checking bot-to-bot cooldown for channel ${message.channelId}.`) try { const lastResponseTime = await redis.get(channelCooldownKey) const currentTime = Math.floor(Date.now() / 1000) if (lastResponseTime && (currentTime - parseInt(lastResponseTime)) < cooldownPeriod) { log(`Channel ${message.channelId} is in bot-to-bot cooldown until ${parseInt(lastResponseTime) + cooldownPeriod}. Skipping.`) return } } catch (error) { log(`Failed to check channel bot-to-bot cooldown: ${error}`) } } // Check if last message in channel was from a user const lastMessageTypeKey = `channel:${message.channelId}:last_message_type` if (isBotMessage) { try { const lastMessageType = await redis.get(lastMessageTypeKey) if (lastMessageType !== 'user') { log(`Skipping bot message: Last message in channel ${message.channelId} was not from a user (type: ${lastMessageType}).`) return } } catch (error) { log(`Failed to check last message type: ${error}`) } } // Update last message type try { await redis.set(lastMessageTypeKey, isBotMessage ? 'bot' : 'user', { EX: 3600 }) // 1 hour TTL log(`Set last_message_type to ${isBotMessage ? 'bot' : 'user'} for channel ${message.channelId}`) } catch (error) { log(`Failed to set last message type: ${error}`) } // Log response trigger log(isMentioned ? 'Responding to mention' : isBotMessage ? 'Responding to bot message' : 'Responding due to random chance') // Default stream to false let shouldStream = false // Params for Preferences Fetching const maxRetries = 3 const delay = 1000 // in milliseconds try { // Retrieve Server/Guild Preferences let attempt = 0 let serverConfig; while (attempt < maxRetries) { try { serverConfig = await new Promise((resolve, reject) => { getServerConfig(`${message.guildId}-config.json`, (config) => { if (config === undefined) { redis.set(`server:${message.guildId}:config`, JSON.stringify({ options: { 'toggle-chat': true } })) .catch(err => log(`Failed to set default server config in Redis: ${err}`)); reject(new Error('Failed to locate or create Server Preferences\n\nPlease try chatting again...')) } else if (!config.options['toggle-chat']) { reject(new Error('Admin(s) have disabled chat features.\n\nPlease contact your server\'s admin(s).')) } else { resolve(config) } }) }) break } catch (error) { ++attempt if (attempt < maxRetries) { log(`Attempt ${attempt} failed for Server Preferences. Retrying in ${delay}ms...`) await new Promise(ret => setTimeout(ret, delay)) } else { log(`Could not retrieve Server Preferences after ${maxRetries} attempts`) if (!isBotMessage) { // Only reply with error for user messages message.reply(getFriendlyError()) } return } } } // Retrieve User Preferences from Redis attempt = 0 let userConfig: UserConfig | undefined const userConfigKey = `user:${message.author.username}:config` while (attempt < maxRetries) { try { userConfig = await new Promise((resolve, reject) => { redis.get(userConfigKey).then(configRaw => { let config: UserConfig | undefined if (configRaw) { try { config = JSON.parse(configRaw) } catch (parseError) { log(`Failed to parse user config JSON: ${parseError}`) reject(parseError) return } } if (!config) { const defaultConfig: UserConfig = { options: { 'message-style': false, 'switch-model': defaultModel, 'modify-capacity': 50, 'message-stream': false } } redis.set(userConfigKey, JSON.stringify(defaultConfig)) .catch(err => log(`Failed to set default user config in Redis: ${err}`)); log(`Created default config for ${message.author.username}`) reject(new Error('No User Preferences is set up.\n\nCreating preferences with defaults.\nPlease try chatting again.')) return } if (typeof config.options['modify-capacity'] === 'number') { log(`New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`) msgHist.capacity = config.options['modify-capacity'] } else { log(`Capacity is undefined, using default capacity of 50.`) msgHist.capacity = 50 } shouldStream = config.options['message-stream'] || false if (typeof config.options['switch-model'] !== 'string') { reject(new Error(`No Model was set. Please set a model by running \`/switch-model \`.\n\nIf you do not have any models. Run \`/pull-model \`.`)) } resolve(config) }).catch(err => { log(`Redis error fetching user config: ${err}`) reject(err) }) }) break } catch (error) { ++attempt if (attempt < maxRetries) { log(`Attempt ${attempt} failed for User Preferences. Retrying in ${delay}ms...`) await new Promise(ret => setTimeout(ret, delay)) } else { log(`Could not retrieve User Preferences after ${maxRetries} attempts`) if (!isBotMessage) { // Only reply with error for user messages message.reply(getFriendlyError()) } return } } } // Retrieve Channel Messages from Redis let chatMessages: UserMessage[] = [] const channelHistoryKey = `channel:${message.channelId}:${message.author.username}:history` try { const historyRaw = await redis.get(channelHistoryKey) if (historyRaw) { try { chatMessages = JSON.parse(historyRaw) log(`Retrieved ${chatMessages.length} messages from Redis for ${channelHistoryKey}`) } catch (parseError) { log(`Failed to parse channel history JSON: ${parseError}`) chatMessages = [] } } else { log(`No history found for ${channelHistoryKey}. Initializing empty history.`) chatMessages = [] } } catch (error) { log(`Failed to retrieve channel history from Redis: ${error}. Using empty history.`) chatMessages = [] } if (!userConfig) { log(`Failed to initialize User Preference for **${message.author.username}**. No config available.`) if (!isBotMessage) { // Only reply with error for user messages message.reply(getFriendlyError()) } return } // Get message attachment if exists const attachment = message.attachments.first() let messageAttachment: string[] = [] if (attachment && attachment.name?.endsWith(".txt")) { try { cleanedMessage += await getTextFileAttachmentData(attachment) } catch (error) { log(`Failed to process text attachment: ${error}`) } } else if (attachment) { try { messageAttachment = await getAttachmentData(attachment) } catch (error) { log(`Failed to process attachment: ${error}`) } } const model: string = userConfig.options['switch-model'] // Load personality let personality: string try { const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) const personalityPath = path.join(__dirname, '../../src/personality.json') const personalityData = await fs.readFile(personalityPath, 'utf-8') const personalityJson = JSON.parse(personalityData) personality = personalityJson.character || 'You are a friendly and helpful AI assistant.' } catch (error) { log(`Failed to load personality.json: ${error}`) personality = 'You are a friendly and helpful AI assistant.' } // Get user or bot sentiment from Redis const userSentimentKey = `user:${message.author.id}:sentiment` const botSentimentKey = `bot:self_sentiment` let userSentiment: number let botSentiment: number // Handle sentiment for bot or user messages if (isBotMessage) { try { const botSentimentRaw = await redis.get(userSentimentKey) userSentiment = parseFloat(botSentimentRaw || '0.50') if (isNaN(userSentiment) || userSentiment < 0 || userSentiment > 1) { log(`Invalid bot sentiment for ${message.author.id}: ${botSentimentRaw}. Using default 0.50.`) userSentiment = 0.50 await redis.set(userSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`)) } } catch (error) { log(`Failed to get bot sentiment from Redis: ${error}`) userSentiment = 0.50 await redis.set(userSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`)) } } else { try { const userSentimentRaw = await redis.get(userSentimentKey) userSentiment = parseFloat(userSentimentRaw || '0.50') if (isNaN(userSentiment) || userSentiment < 0 || userSentiment > 1) { log(`Invalid user sentiment for ${message.author.id}: ${userSentimentRaw}. Using default 0.50.`) userSentiment = 0.50 await redis.set(userSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default user sentiment: ${err.message}`)) } } catch (error) { log(`Failed to get user sentiment from Redis: ${error}`) userSentiment = 0.50 await redis.set(userSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default user sentiment: ${err.message}`)) } } try { const botSentimentRaw = await redis.get(botSentimentKey) botSentiment = parseFloat(botSentimentRaw || '0.50') if (botSentimentRaw === null) { log(`Bot sentiment not initialized. Setting to 0.50.`) botSentiment = 0.50 await redis.set(botSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`)) } else if (isNaN(botSentiment) || botSentiment < 0 || botSentiment > 1) { log(`Invalid bot sentiment: ${botSentimentRaw}. Using default 0.50.`) botSentiment = 0.50 await redis.set(botSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`)) } } catch (error) { log(`Failed to get bot sentiment from Redis: ${error}`) botSentiment = 0.50 await redis.set(botSentimentKey, '0.50').catch((err: Error) => log(`Failed to set default bot sentiment: ${err.message}`)) } // Log initial sentiments with two decimals log(`Initial sentiments - User ${message.author.id}: ${userSentiment.toFixed(2)}, Bot: ${botSentiment.toFixed(2)}`) // Construct sentiment data for prompt const sentimentData = `User ${message.author.id} sentiment: ${userSentiment.toFixed(2)}, Bot sentiment: ${botSentiment.toFixed(2)}` // Add context for bot-to-bot interaction const messageContext = isBotMessage ? `Responding to another bot (${message.author.tag})` : `Responding to user ${message.author.tag}` // Construct prompt with [CHARACTER], [SENTIMENT], and [CONTEXT] const prompt = `[CHARACTER]\n${personality}\n[SENTIMENT]\n${sentimentData}\n[CONTEXT]\n${messageContext}\n[USER_INPUT]\n${cleanedMessage}` // Set up message history queue msgHist.setQueue(chatMessages) while (msgHist.size() >= msgHist.capacity) msgHist.dequeue() // Add user message to history msgHist.enqueue({ role: 'user', content: cleanedMessage, images: messageAttachment || [] }) // Call Ollama let response; try { response = await ollama.chat({ model, messages: [{ role: 'user', content: prompt }], stream: shouldStream }) } catch (error) { log(`Ollama chat error: ${error}`) if (!isBotMessage) { // Only reply with error for user messages message.reply(getFriendlyError()) } msgHist.pop() return } // Parse JSON response let jsonResponse: ModelResponse try { // Log raw response for debugging log(`Raw model response: ${response.message.content}`) // Strip Markdown code fences if present let content = response.message.content content = content.replace(/^```json\n|```$/g, '').trim() jsonResponse = JSON.parse(content) if (!jsonResponse.status || !jsonResponse.reply) { throw new Error('Missing status or reply in model response') } } catch (error) { log(`Failed to parse model response: ${error}`) if (!isBotMessage) { // Only reply with error for user messages message.reply(getFriendlyError()) } msgHist.pop() return } if (jsonResponse.status === 'error') { log(`Model returned error status: ${jsonResponse.reply}`) if (!isBotMessage) { // Only reply with error for user messages message.reply(getFriendlyError()) } msgHist.pop() return } // Execute redis_ops if (jsonResponse.metadata?.redis_ops) { for (const op of jsonResponse.metadata.redis_ops) { try { if (op.action === 'set' && op.key && op.value !== undefined) { // Validate sentiment value const value = parseFloat(op.value.toString()) if (isNaN(value) || value < 0 || value > 1) { log(`Invalid sentiment value for ${op.key}: ${op.value}. Skipping.`) continue } // Store with two decimal places await redis.set(op.key, value.toFixed(2)) log(`Set ${op.key} to ${value.toFixed(2)}`) } else if (op.action === 'get' && op.key) { const value = await redis.get(op.key) log(`Got ${op.key}: ${value}`) } else { log(`Invalid redis_op: ${JSON.stringify(op)}. Skipping.`) } } catch (error) { log(`Redis operation failed for ${op.key}: ${error}`) } } } // Log updated sentiments with two decimals if (jsonResponse.metadata) { log(`Updated sentiments - Self: ${(jsonResponse.metadata.self_sentiment || 0).toFixed(2)}, User ${message.author.id}: ${(jsonResponse.metadata.user_sentiment[message.author.id] || 0).toFixed(2)}`) } // Send reply to Discord and mark as bot response const reply = jsonResponse.reply || 'Huh?' let replyMessage; try { replyMessage = await message.reply(reply) } catch (error) { log(`Failed to send reply to Discord: ${error}`) msgHist.pop() return } if (isBotMessage) { try { await redis.set(`message:${replyMessage.id}:is_bot_response`, 'true', { EX: 3600 }) // 1 hour TTL log(`Marked message ${replyMessage.id} as bot response`) // Set channel-wide bot-to-bot cooldown const currentTime = Math.floor(Date.now() / 1000) await redis.set(channelCooldownKey, currentTime.toString(), { EX: cooldownPeriod }) log(`Set channel ${message.channelId} bot-to-bot cooldown until ${currentTime + cooldownPeriod}`) } catch (error) { log(`Failed to mark message as bot response or set channel cooldown: ${error}`) } } // Update message history in Redis while (msgHist.size() >= msgHist.capacity) msgHist.dequeue() msgHist.enqueue({ role: 'assistant', content: reply, images: messageAttachment || [] }) try { await redis.set(channelHistoryKey, JSON.stringify(msgHist.getItems())) log(`Saved ${msgHist.size()} messages to Redis for ${channelHistoryKey}`) } catch (error) { log(`Failed to save channel history to Redis: ${error}`) } } catch (error: any) { log(`Error in message processing: ${error.message}`) if (!isBotMessage) { // Only reply with error for user messages message.reply(getFriendlyError()) } msgHist.pop() } })