510 lines
22 KiB
TypeScript
510 lines
22 KiB
TypeScript
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 <model of choice>\`.\n\nIf you do not have any models. Run \`/pull-model <model name>\`.`))
|
||
}
|
||
|
||
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()
|
||
}
|
||
})
|