Files
discord-aidolls/src/events/messageCreate.ts
Alex efa8b84d75
Some checks failed
Builds / Discord-Node-Build (push) Has been cancelled
Builds / Discord-Ollama-Container-Build (push) Has been cancelled
Coverage / Discord-Node-Coverage (push) Has been cancelled
Added bot-to-bot replies
2025-05-20 14:10:11 -04:00

352 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 bots 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 <model of choice>\`.\n\nIf you do not have any models. Run \`/pull-model <model name>\`.`))
}
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, Im 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('<bot_id>', 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 didnt 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}*`)
}
})