352 lines
15 KiB
TypeScript
352 lines
15 KiB
TypeScript
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 <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, 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('<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 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}*`)
|
||
}
|
||
})
|