import { TextChannel } from 'discord.js' import { event, Events, normalMessage, UserMessage, clean } from '../utils/index.js' import { getChannelInfo, getServerConfig, getUserConfig, openChannelInfo, openConfig, UserConfig, getAttachmentData, getTextFileAttachmentData } from '../utils/index.js' import { redis } from '../client.js' import fs from 'fs/promises' import path from 'path' import { fileURLToPath } from 'url' // 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 } } /** * 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) => { const clientId = client.user!.id let cleanedMessage = clean(message.content, clientId) log(`Message "${cleanedMessage}" from ${message.author.tag} in channel/thread ${message.channelId}.`) // Check if message mentions the bot or passes random chance (30%) const isFromBot = message.author.bot && message.author.id !== clientId; const isMentioned = message.mentions.has(clientId) const isCommand = message.content.startsWith('/') if (isFromBot) { // Check interaction key to prevent rapid back-and-forth const otherBotId = message.author.id const interactionKey = `bot_interaction:${[clientId, otherBotId].sort().join(':')}` const interactionExists = await redis.exists(interactionKey) if (interactionExists) { log('Interaction cooldown active, not responding') return } // Determine probability let respondProbability = 0 if (isMentioned) { respondProbability = 0.9 // 90% chance if mentioned } else { respondProbability = 0.2 // 20% chance if not mentioned } const shouldRespond = Math.random() < respondProbability if (!shouldRespond) return // Set interaction key with 60s expiration await redis.set(interactionKey, '1', 'EX', 60) } else if (!message.author.bot) { // Human message const randomChance = Math.random() < 0.30 if (!isMentioned && (isCommand || !randomChance)) return } else { // Message from self: ignore return } // Log response trigger log(isMentioned ? 'Responding to mention' : 'Responding due to random chance') // Log response trigger log(isFromBot ? 'Responding to bot message' : (isMentioned ? 'Responding to mention' : 'Responding due to random chance')) // Load and process bot’s own history const historyFile = `${message.channelId}-${client.user.username}.json` // 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 while (attempt < maxRetries) { try { await new Promise((resolve, reject) => { getServerConfig(`${message.guildId}-config.json`, (config) => { if (config === undefined) { openConfig(`${message.guildId}-config.json`, 'toggle-chat', true) 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 { throw new Error(`Could not retrieve Server Preferences, please try chatting again...`) } } } // Retrieve User Preferences attempt = 0 let userConfig: UserConfig | undefined while (attempt < maxRetries) { try { userConfig = await new Promise((resolve, reject) => { getUserConfig(`${message.author.username}-config.json`, (config) => { if (config === undefined) { openConfig(`${message.author.username}-config.json`, 'message-style', false) openConfig(`${message.author.username}-config.json`, 'switch-model', defaultModel) reject(new Error('No User Preferences is set up.\n\nCreating preferences file with `message-style` set as `false` for regular message style.\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 ${msgHist.capacity}.`) } shouldStream = config.options['message-stream'] as boolean || 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) }) }) 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 { throw new Error(`Could not retrieve User Preferences, please try chatting again...`) } } } // Retrieve Channel Messages let chatMessages: UserMessage[] = await new Promise((resolve) => { getChannelInfo(`${message.channelId}-${message.author.username}.json`, (channelInfo) => { if (channelInfo?.messages) { resolve(channelInfo.messages) } else { log(`Channel/Thread ${message.channelId}-${message.author.username} does not exist. File will be created shortly...`) resolve([]) } }) }) if (chatMessages.length === 0) { chatMessages = await new Promise((resolve, reject) => { openChannelInfo(message.channelId, message.channel as TextChannel, message.author.tag) getChannelInfo(`${message.channelId}-${message.author.username}.json`, (config) => { if (config?.messages) { resolve(config.messages) } else { reject(new Error(`Failed to find ${message.author.username}'s history. Try chatting again.`)) } }) }) } if (!userConfig) { throw new Error(`Failed to initialize User Preference for **${message.author.username}**.\n\nIt's likely you do not have a model set. Please use the \`switch-model\` command to do that.`) } // Get message attachment if exists const attachment = message.attachments.first() let messageAttachment: string[] = [] if (attachment && attachment.name?.endsWith(".txt")) { cleanedMessage += await getTextFileAttachmentData(attachment) } else if (attachment) { messageAttachment = await getAttachmentData(attachment) } 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 and bot sentiment from Redis const userSentimentKey = `user:${message.author.id}:sentiment` const botSentimentKey = `bot:self_sentiment` let userSentiment: number let botSentiment: number 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 (isNaN(botSentiment) || botSentiment < 0 || botSentiment > 1) { log(`Invalid bot sentiment for ${botSentimentKey}: ${botSentimentRaw}. Using default 0.50.`) botSentiment = 0.50 await redis.set(botSentimentKey, '0.50') } } catch (error) { log(`Failed to get bot sentiment from Redis: ${error}`) botSentiment = 0.50 await redis.set(botSentimentKey, '0.50') } // Construct sentiment data with bot ID const sentimentData = `User ${message.author.id} sentiment: ${userSentiment.toFixed(2)}, Bot ${clientId} sentiment: ${botSentiment.toFixed(2)}` // 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)}` // Construct prompt with [CHARACTER] and [SENTIMENT] const prompt = `[CHARACTER]\n${personality}\n[SENTIMENT]\n${sentimentData}\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 const response = await ollama.chat({ model, messages: [{ role: 'user', content: prompt }], stream: shouldStream }) // 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}`) message.reply('Sorry, I’m having trouble thinking right now. Try again?') msgHist.pop() return } if (jsonResponse.status === 'error') { message.reply(jsonResponse.reply) msgHist.pop() return } // Execute redis_ops if (jsonResponse.metadata?.redis_ops) { for (const op of jsonResponse.metadata.redis_ops) { try { const key = op.key.replace('', clientId) if (op.action === 'set' && op.value !== undefined) { const value = parseFloat(op.value.toString()) if (isNaN(value) || value < 0 || value > 1) { log(`Invalid sentiment value for ${key}: ${op.value}. Skipping.`) continue } await redis.set(key, value.toFixed(2)) log(`Set ${key} to ${value.toFixed(2)}`) } else if (op.action === 'get' && op.key) { const value = await redis.get(key) log(`Got ${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 const reply = jsonResponse.reply || 'Sorry, I didn’t get that. Can you try again?' await message.reply(reply) // Update message history while (msgHist.size() >= msgHist.capacity) msgHist.dequeue() msgHist.enqueue({ role: 'assistant', content: reply, images: messageAttachment || [] }) // Save updated history openChannelInfo(message.channelId, message.channel as TextChannel, message.author.tag, msgHist.getItems()) } catch (error: any) { msgHist.pop() message.reply(`**Error Occurred:**\n\n**Reason:** *${error.message}*`) } })