From de413f90e19a2b7a40e2b15676fc73b3b02aaac5 Mon Sep 17 00:00:00 2001 From: Alex Date: Wed, 21 May 2025 07:18:28 -0400 Subject: [PATCH] everything is broken yay --- Dockerfile | 3 +- discord-aidolls@0.1.0 | 0 docker-compose.yml | 2 +- package.json | 1 + src/commands/capacity.ts | 22 +-- src/commands/cleanUserChannelHistory.ts | 23 +-- src/commands/messageStream.ts | 16 +- src/commands/switchModel.ts | 62 +++---- src/commands/threadCreate.ts | 14 +- src/commands/threadPrivateCreate.ts | 15 +- src/events/messageCreate.ts | 214 +++++++++++++---------- src/utils/configInterfaces.ts | 152 ++++++++++------ src/utils/handlers/chatHistoryHandler.ts | 138 +++++++-------- src/utils/handlers/configHandler.ts | 132 ++++++-------- src/utils/index.ts | 17 +- src/utils/messageNormal.ts | 137 ++++++++------- 16 files changed, 489 insertions(+), 459 deletions(-) create mode 100644 discord-aidolls@0.1.0 diff --git a/Dockerfile b/Dockerfile index e32013c..572e910 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,9 +1,8 @@ -# Existing Dockerfile content FROM node:20.11.0-slim WORKDIR /app COPY package*.json ./ RUN npm install COPY . . -RUN mkdir -p /app/data && chown -R node:node /app/data +RUN mkdir -p /app/data && chown -R node:node /app/data && chmod -R u+rw /app/data USER node CMD ["npm", "run", "prod"] diff --git a/discord-aidolls@0.1.0 b/discord-aidolls@0.1.0 new file mode 100644 index 0000000..e69de29 diff --git a/docker-compose.yml b/docker-compose.yml index 2ae390f..9c36f1b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -15,7 +15,7 @@ services: redis_discord-net: ipv4_address: ${DISCORD_IP} volumes: - - discord_data:/app/data + - ./discord_data:/app/data - ./src:/app/src healthcheck: test: ["CMD", "redis-cli", "-h", "${REDIS_IP}", "-p", "${REDIS_PORT}", "PING"] diff --git a/package.json b/package.json index 11e8d6a..2b2d110 100644 --- a/package.json +++ b/package.json @@ -46,3 +46,4 @@ "node": ">=22.12.0" } } + diff --git a/src/commands/capacity.ts b/src/commands/capacity.ts index 20a2391..7c547bd 100644 --- a/src/commands/capacity.ts +++ b/src/commands/capacity.ts @@ -5,7 +5,6 @@ export const Capacity: SlashCommand = { name: 'modify-capacity', description: 'maximum amount messages bot will hold for context.', - // set available user options to pass to the command options: [ { name: 'context-capacity', @@ -15,20 +14,23 @@ export const Capacity: SlashCommand = { } ], - // Query for message information and set the style run: async (client: Client, interaction: CommandInteraction) => { - // fetch channel and message const channel = await client.channels.fetch(interaction.channelId) if (!channel || !UserCommand.includes(channel.type)) return - // set state of bot chat features - openConfig(`${interaction.user.username}-config.json`, interaction.commandName, - interaction.options.get('context-capacity')?.value - ) + const capacity = interaction.options.get('context-capacity')?.value + if (typeof capacity !== 'number' || capacity <= 0) { + await interaction.reply({ + content: 'Please provide a valid positive number for capacity.', + flags: MessageFlags.Ephemeral + }) + return + } - interaction.reply({ - content: `Max message history is now set to \`${interaction.options.get('context-capacity')?.value}\``, + await openConfig(`${interaction.user.id}-config.json`, 'modify-capacity', capacity) + await interaction.reply({ + content: `Max message history is now set to \`${capacity}\`.`, flags: MessageFlags.Ephemeral }) } -} \ No newline at end of file +} diff --git a/src/commands/cleanUserChannelHistory.ts b/src/commands/cleanUserChannelHistory.ts index 0eb2c84..5206283 100644 --- a/src/commands/cleanUserChannelHistory.ts +++ b/src/commands/cleanUserChannelHistory.ts @@ -5,31 +5,26 @@ export const ClearUserChannelHistory: SlashCommand = { name: 'clear-user-channel-history', description: 'clears history for user in the current channel', - // Clear channel history for intended user run: async (client: Client, interaction: CommandInteraction) => { - // fetch current channel const channel: Channel | null = await client.channels.fetch(interaction.channelId) - - // if not an existing channel or a GuildText, fail command if (!channel || !UserCommand.includes(channel.type)) return - // clear channel info for user const successfulWipe = await clearChannelInfo( interaction.channelId, interaction.channel as TextChannel, - interaction.user.username + interaction.user.id ) - // check result of clearing history - if (successfulWipe) - interaction.reply({ - content: `History cleared in **this channel** cleared for **${interaction.user.username}**.`, + if (successfulWipe) { + await interaction.reply({ + content: `History cleared in **this channel** for **${interaction.user.username}**.`, flags: MessageFlags.Ephemeral }) - else - interaction.reply({ - content: `History was not be found for **${interaction.user.username}** in **this channel**.\n\nPlease chat with **${client.user?.username}** to start a chat history.`, + } else { + await interaction.reply({ + content: `History was not found for **${interaction.user.username}** in **this channel**.\n\nPlease chat with **${client.user?.username}** to start a chat history.`, flags: MessageFlags.Ephemeral }) + } } -} \ No newline at end of file +} diff --git a/src/commands/messageStream.ts b/src/commands/messageStream.ts index b66fc8f..1aa9853 100644 --- a/src/commands/messageStream.ts +++ b/src/commands/messageStream.ts @@ -5,7 +5,6 @@ export const MessageStream: SlashCommand = { name: 'message-stream', description: 'change preference on message streaming from ollama. WARNING: can be very slow due to Discord limits.', - // user option(s) for setting stream options: [ { name: 'stream', @@ -15,20 +14,15 @@ export const MessageStream: SlashCommand = { } ], - // change preferences based on command run: async (client: Client, interaction: CommandInteraction) => { - // verify channel const channel = await client.channels.fetch(interaction.channelId) if (!channel || !UserCommand.includes(channel.type)) return - // save value to json and write to it - openConfig(`${interaction.user.username}-config.json`, interaction.commandName, - interaction.options.get('stream')?.value - ) - - interaction.reply({ - content: `Message streaming is now set to: \`${interaction.options.get('stream')?.value}\``, + const stream = interaction.options.get('stream')?.value + await openConfig(`${interaction.user.id}-config.json`, 'message-stream', stream) + await interaction.reply({ + content: `Message streaming is now set to: \`${stream}\``, flags: MessageFlags.Ephemeral }) } -} \ No newline at end of file +} diff --git a/src/commands/switchModel.ts b/src/commands/switchModel.ts index c81c1e9..9b1f07f 100644 --- a/src/commands/switchModel.ts +++ b/src/commands/switchModel.ts @@ -1,13 +1,12 @@ -import { ApplicationCommandOptionType, Client, CommandInteraction } from "discord.js" -import { ollama } from "../client.js" -import { ModelResponse } from "ollama" -import { openConfig, UserCommand, SlashCommand } from "../utils/index.js" +import { ApplicationCommandOptionType, Client, CommandInteraction, MessageFlags } from 'discord.js' +import { ollama } from '../client.js' +import { ModelResponse } from 'ollama' +import { openConfig, UserCommand, SlashCommand } from '../utils/index.js' export const SwitchModel: SlashCommand = { name: 'switch-model', description: 'switches current model to use.', - // set available user options to pass to the command options: [ { name: 'model-to-use', @@ -17,51 +16,38 @@ export const SwitchModel: SlashCommand = { } ], - // Switch user preferred model if available in local library run: async (client: Client, interaction: CommandInteraction) => { await interaction.deferReply() + const modelInput: string = interaction.options.get('model-to-use')!.value as string - const modelInput: string = interaction.options.get('model-to-use')!!.value as string - - // fetch channel and message const channel = await client.channels.fetch(interaction.channelId) if (!channel || !UserCommand.includes(channel.type)) return try { - // Phase 1: Switch to the model let switchSuccess = false - await ollama.list() - .then(response => { - for (const model in response.models) { - const currentModel: ModelResponse = response.models[model] - if (currentModel.name.startsWith(modelInput)) { - openConfig(`${interaction.user.username}-config.json`, interaction.commandName, modelInput) - - // successful switch - interaction.editReply({ - content: `Successfully switched to **${modelInput}** as the preferred model for ${interaction.user.username}.` - }) - switchSuccess = true - } + await ollama.list().then(response => { + for (const model of response.models) { + if (model.name.startsWith(modelInput)) { + openConfig(`${interaction.user.id}-config.json`, 'switch-model', modelInput) + interaction.editReply({ + content: `Successfully switched to **${modelInput}** as the preferred model for ${interaction.user.username}.` + }) + switchSuccess = true } - }) - // todo: problem can be here if async messes up - if (switchSuccess) { - // set model now that it exists - openConfig(`${interaction.user.username}-config.json`, interaction.commandName, modelInput) - return - } + } + }) - // Phase 2: Notify user of failure to find model. - interaction.editReply({ - content: `Could not find **${modelInput}** in local model library.\n\nPlease contact an server admin for access to this model.` + if (switchSuccess) return + + await interaction.editReply({ + content: `Could not find **${modelInput}** in local model library.\n\nPlease contact a server admin to pull this model using \`/pull-model ${modelInput}\`.`, + flags: MessageFlags.Ephemeral }) } catch (error) { - // could not resolve user model switch - interaction.editReply({ - content: `Unable to switch user preferred model to **${modelInput}**.\n\n${error}\n\nPossible solution is to request an server admin run \`/pull-model ${modelInput}\` and try again.` + await interaction.editReply({ + content: `Unable to switch to **${modelInput}**.\n\n${error}\n\nTry asking an admin to run \`/pull-model ${modelInput}\`.`, + flags: MessageFlags.Ephemeral }) - return } } -} \ No newline at end of file +} diff --git a/src/commands/threadCreate.ts b/src/commands/threadCreate.ts index 1b9153b..bb4b4b2 100644 --- a/src/commands/threadCreate.ts +++ b/src/commands/threadCreate.ts @@ -5,9 +5,7 @@ export const ThreadCreate: SlashCommand = { name: 'thread', description: 'creates a thread and mentions user', - // Query for server information run: async (client: Client, interaction: CommandInteraction) => { - // fetch the channel const channel = await client.channels.fetch(interaction.channelId) if (!channel || !AdminCommand.includes(channel.type)) return @@ -17,16 +15,12 @@ export const ThreadCreate: SlashCommand = { type: ChannelType.PublicThread }) - // Send a message in the thread - thread.send(`Hello ${interaction.user} and others! \n\nIt's nice to meet you. Please talk to me by typing **@${client.user?.username}** with your message.`) + await thread.send(`Hello ${interaction.user} and others! \n\nIt's nice to meet you. Please talk to me by typing **@${client.user?.username}** with your message.`); + await openChannelInfo(thread.id, thread as ThreadChannel, interaction.user.id); - // handle storing this chat channel - openChannelInfo(thread.id, thread as ThreadChannel, interaction.user.tag) - - // user only reply - return interaction.reply({ + await interaction.reply({ content: `I can help you in <#${thread.id}> below.`, flags: MessageFlags.Ephemeral }) } -} \ No newline at end of file +} diff --git a/src/commands/threadPrivateCreate.ts b/src/commands/threadPrivateCreate.ts index 578f2eb..33989df 100644 --- a/src/commands/threadPrivateCreate.ts +++ b/src/commands/threadPrivateCreate.ts @@ -5,9 +5,7 @@ export const PrivateThreadCreate: SlashCommand = { name: 'private-thread', description: 'creates a private thread and mentions user', - // Query for server information run: async (client: Client, interaction: CommandInteraction) => { - // fetch the channel const channel = await client.channels.fetch(interaction.channelId) if (!channel || !AdminCommand.includes(channel.type)) return @@ -17,17 +15,12 @@ export const PrivateThreadCreate: SlashCommand = { type: ChannelType.PrivateThread }) - // Send a message in the thread - thread.send(`Hello ${interaction.user}! \n\nIt's nice to meet you. Please talk to me by typing @${client.user?.username} with your prompt.`) + await thread.send(`Hello ${interaction.user}! \n\nIt's nice to meet you. Please talk to me by typing @${client.user?.username} with your prompt.`); + await openChannelInfo(thread.id, thread as ThreadChannel, interaction.user.id); - // handle storing this chat channel - // store: thread.id, thread.name - openChannelInfo(thread.id, thread as ThreadChannel, interaction.user.tag) - - // user only reply - return interaction.reply({ + await interaction.reply({ content: `I can help you in <#${thread.id}>.`, flags: MessageFlags.Ephemeral }) } -} \ No newline at end of file +} diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 88b2011..1c40d5f 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -31,52 +31,45 @@ interface ModelResponse { 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}.') + log(`[Event: messageCreate] 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 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 + log(`[Event: messageCreate] Processing bot message from ${message.author.tag} (ID: ${message.author.id})`) const otherBotId = message.author.id - const interactionKey = 'bot_interaction:${clientId}:${otherBotId}' + const interactionKey = `bot_interaction:${clientId}:${otherBotId}` const interactionExists = await redis.exists(interactionKey) if (interactionExists) { - log('Interaction cooldown active, not responding') + log(`[Event: messageCreate] Interaction cooldown active for ${interactionKey}`) return } - let respondProbability = isMentioned ? 0.9 : 0.2 - if (!isMentioned && Math.random() >= respondProbability) return - - // Set shorter cooldown (e.g., 30s) - await redis.set(interactionKey, '1', { EX: 30 }) + log(`[Event: messageCreate] Respond probability: ${respondProbability}`) + if (!isMentioned && Math.random() >= respondProbability) { + log(`[Event: messageCreate] Skipping response due to probability check`) + return + } + await redis.set(interactionKey, '1', { EX: 15 }) } 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}-${isFromBot ? clientId : message.author.id}.json' + log(isMentioned ? '[Event: messageCreate] Responding to mention' : '[Event: messageCreate] Responding due to random chance') // Default stream to false let shouldStream = false // Params for Preferences Fetching const maxRetries = 3 - const delay = 1000 // in milliseconds + const delay = 1000 + const defaultCapacity = 50 try { // Retrieve Server/Guild Preferences @@ -99,7 +92,7 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client } catch (error) { ++attempt if (attempt < maxRetries) { - log(`Attempt ${attempt} failed for Server Preferences. Retrying in ${delay}ms...`) + log(`[Event: messageCreate] 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...`) @@ -115,33 +108,30 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client 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`, 'message-stream', 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'] + reject(new Error('No User Preferences is set up.\n\nCreating preferences file with `message-stream` set as `false` for regular message style.\nPlease try chatting again.')) } else { - log(`Capacity is undefined, using default capacity of ${msgHist.capacity}.`) + if (typeof config.options['modify-capacity'] === 'number') { + log(`[Event: messageCreate] New Capacity found. Setting Context Capacity to ${config.options['modify-capacity']}.`) + msgHist.capacity = config.options['modify-capacity'] + } else { + log(`[Event: messageCreate] Capacity is undefined, using default capacity of ${defaultCapacity}.`) + msgHist.capacity = defaultCapacity + } + 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) } - - 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...`) + log(`[Event: messageCreate] 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...`) @@ -150,29 +140,56 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client } // 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([]) - } - }) - }) + const safeAuthorId = isFromBot ? clientId : message.author.id + const fileName = `${message.channelId}-${safeAuthorId}.json` + let chatMessages: UserMessage[] = await new Promise((resolve, reject) => { + let attempts = 0 + const maxAttempts = 3 + const retryDelay = 500 - if (chatMessages.length === 0) { - chatMessages = await new Promise((resolve, reject) => { - openChannelInfo(message.channelId, message.channel as TextChannel, isFromBot ? clientId : message.author.tag) - getChannelInfo(`${message.channelId}-${isFromBot ? clientId : message.author.id}.json`, (config) => { + const tryGetChannelInfo = async () => { + log(`[Event: messageCreate] Attempt ${attempts + 1} to read ${fileName}`) + getChannelInfo(fileName, async (config) => { if (config?.messages) { + log(`[Event: messageCreate] Successfully loaded history from ${fileName}`) resolve(config.messages) } else { - reject(new Error(`Failed to find history for ${isFromBot ? clientId : message.author.tag}. Try chatting again.`)) + log(`[Event: messageCreate] Channel/Thread ${fileName} does not exist. Creating...`) + try { + await openChannelInfo(message.channelId, message.channel as TextChannel, safeAuthorId, []) + try { + const filePath = path.join('/app/data', fileName) + await fs.access(filePath, fs.constants.R_OK | fs.constants.W_OK) + log(`[Event: messageCreate] Confirmed ${fileName} created`) + const data = await fs.readFile(filePath, 'utf-8') + const config = JSON.parse(data) + if (config?.messages) { + resolve(config.messages) + } else { + log(`[Event: messageCreate] Created ${fileName} but contains invalid data: ${data}`) + if (++attempts < maxAttempts) { + setTimeout(tryGetChannelInfo, retryDelay) + } else { + reject(new Error(`Failed to find or create valid history for ${safeAuthorId} after ${maxAttempts} attempts. Try chatting again.`)) + } + } + } catch (accessError) { + log(`[Event: messageCreate] File ${fileName} not found or inaccessible after creation: ${accessError}`) + if (++attempts < maxAttempts) { + setTimeout(tryGetChannelInfo, retryDelay) + } else { + reject(new Error(`Failed to verify ${fileName} after creation. Try chatting again.`)) + } + } + } catch (error) { + log(`[Event: messageCreate] Failed to create ${fileName}: ${error}`) + reject(error) + } } }) - }) - } + } + tryGetChannelInfo() + }) 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.`) @@ -194,18 +211,24 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client try { const __filename = fileURLToPath(import.meta.url) const __dirname = path.dirname(__filename) - const personalityPath = path.join(__dirname, '../../src/personality.json') + let personalityFile = 'personality.json' + if (client.user!.username.includes('kuroki-tomoko')) { + personalityFile = 'personality-kuroki-tomoko.json' + } else if (client.user!.username.includes('nagatoro-hayase')) { + personalityFile = 'personality-nagatoro-hayase.json' + } + const personalityPath = path.join(__dirname, '../../src', personalityFile) 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}`) + log(`[Event: messageCreate] Failed to load personality.json: ${error}`) personality = 'You are a friendly and helpful AI assistant.' } // Get user and bot sentiment from Redis - const userSentimentKey = 'bot:${clientId}:user:${message.author.id}:sentiment' - const botSentimentKey = 'bot:${clientId}:self_sentiment' + const userSentimentKey = `bot:${clientId}:user:${message.author.id}:sentiment` + const botSentimentKey = `bot:${clientId}:self_sentiment` let userSentiment: number let botSentiment: number @@ -213,15 +236,13 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client const userSentimentRaw = await redis.get(userSentimentKey) userSentiment = parseFloat(userSentimentRaw || '0.50') if (isNaN(userSentiment) || userSentiment < 0 || userSentiment > 1) { - log(`Invalid user sentiment for ${userSentimentKey}: ${userSentimentRaw}. Attempting to retrieve last valid value.`) - userSentiment = parseFloat(await redis.get(userSentimentKey) || '0.50') - if (isNaN(userSentiment)) userSentiment = 0.50 + log(`[Event: messageCreate] Invalid user sentiment for ${userSentimentKey}: ${userSentimentRaw}. Using default.`) + userSentiment = 0.50 await redis.set(userSentimentKey, userSentiment.toFixed(2)) } } catch (error) { - log(`Failed to get user sentiment from Redis: ${error}`) - userSentiment = parseFloat(await redis.get(userSentimentKey) || '0.50') - if (isNaN(userSentiment)) userSentiment = 0.50 + log(`[Event: messageCreate] Failed to get user sentiment from Redis: ${error}`) + userSentiment = 0.50 await redis.set(userSentimentKey, userSentiment.toFixed(2)) } @@ -229,26 +250,21 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client 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}. Attempting to retrieve last valid value.`) - botSentiment = parseFloat(await redis.get(botSentimentKey) || '0.50') - if (isNaN(botSentiment)) botSentiment = 0.50 + log(`[Event: messageCreate] Invalid bot sentiment for ${botSentimentKey}: ${botSentimentRaw}. Using default.`) + botSentiment = 0.50 await redis.set(botSentimentKey, botSentiment.toFixed(2)) } } catch (error) { - log(`Failed to get bot sentiment from Redis: ${error}`) - botSentiment = parseFloat(await redis.get(botSentimentKey) || '0.50') - if (isNaN(botSentiment)) botSentiment = 0.50 + log(`[Event: messageCreate] Failed to get bot sentiment from Redis: ${error}`) + botSentiment = 0.50 await redis.set(botSentimentKey, botSentiment.toFixed(2)) - } + } // 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)}` + // Log initial sentiments + log(`[Event: messageCreate] Retrieved sentiments - User ${message.author.id}: ${userSentiment.toFixed(2)}, Bot: ${botSentiment.toFixed(2)}`) // Construct prompt with [CHARACTER] and [SENTIMENT] const prompt = `[CHARACTER]\n${personality}\n[SENTIMENT]\n${sentimentData}\n[USER_INPUT]\n${cleanedMessage}` @@ -268,24 +284,30 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client const response = await ollama.chat({ model, messages: [{ role: 'user', content: prompt }], - stream: shouldStream + stream: shouldStream, + options: { temperature: 0.5 } }) // Parse JSON response let jsonResponse: ModelResponse + let content: string = '' try { - // Log raw response for debugging - log(`Raw model response: ${response.message.content}`) - // Strip Markdown code fences if present - let content = response.message.content + log(`[Event: messageCreate] Raw model response: ${response.message.content}`) + content = response.message.content content = content.replace(/```(?:json)?\n?/g, '').trim() + if (!content.startsWith('{') || !content.endsWith('}')) { + log(`[Event: messageCreate] Invalid JSON format detected: ${content}`) + message.reply('Sorry, I’m having trouble thinking right now. Try again?') + msgHist.pop() + return + } 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 had a brain malfunction!') + log(`[Event: messageCreate] Failed to parse model response: ${error}\nRaw content: ${content}`) + message.reply('Sorry, I’m having trouble thinking right now. Try again?') msgHist.pop() return } @@ -298,33 +320,33 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client // Execute redis_ops if (jsonResponse.metadata?.redis_ops) { - log(`Model redis_ops: ${JSON.stringify(jsonResponse.metadata.redis_ops)}`) + log(`[Event: messageCreate] Model redis_ops output: ${JSON.stringify(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.`) + log(`[Event: messageCreate] Invalid sentiment value for ${key}: ${op.value}. Skipping.`) continue } await redis.set(key, value.toFixed(2)) - log(`Set ${key} to ${value.toFixed(2)}`) + log(`[Event: messageCreate] Set ${key} to ${value.toFixed(2)}`) } else if (op.action === 'get' && op.key) { const value = await redis.get(key) - log(`Got ${key}: ${value}`) + log(`[Event: messageCreate] Got ${key}: ${value}`) } else { - log(`Invalid redis_op: ${JSON.stringify(op)}. Skipping.`) + log(`[Event: messageCreate] Invalid redis_op: ${JSON.stringify(op)}. Skipping.`) } } catch (error) { - log(`Redis operation failed for ${op.key}: ${error}`) + log(`[Event: messageCreate] Redis operation failed for ${op.key}: ${error}`) } } } - // Log updated sentiments with two decimals + // Log updated sentiments 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)}`) + log(`[Event: messageCreate] 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 @@ -340,9 +362,9 @@ export default event(Events.MessageCreate, async ({ log, msgHist, ollama, client }) // Save updated history - openChannelInfo(message.channelId, message.channel as TextChannel, message.author.tag, msgHist.getItems()) + await openChannelInfo(message.channelId, message.channel as TextChannel, safeAuthorId, msgHist.getItems()) } catch (error: any) { msgHist.pop() - message.reply(`**Error Occurred:**\n\n**Reason:** *${error.message}*`) + message.reply(`**Error Occurred:**\n\n**Reason:** *${error.message}*\n\nPlease try again or contact the server admin if this persists.`) } }) diff --git a/src/utils/configInterfaces.ts b/src/utils/configInterfaces.ts index 98bb2e1..61f5fdf 100644 --- a/src/utils/configInterfaces.ts +++ b/src/utils/configInterfaces.ts @@ -1,67 +1,119 @@ -import { ChannelType } from 'discord.js' -import { UserMessage } from './index.js' - -export interface UserConfiguration { - 'message-stream'?: boolean, - 'modify-capacity': number, - 'switch-model': string -} - -export interface ServerConfiguration { - 'toggle-chat'?: boolean, -} +import { TextChannel, ThreadChannel } from 'discord.js' +import { Channel, UserMessage } from '../index.js' +import fs from 'fs/promises' +import path from 'path' /** - * Parent Configuration interface + * Method to check if a thread history file exists * - * @see ServerConfiguration server settings per guild - * @see UserConfiguration user configurations (only for the user for any server) + * @param channel parent thread of the requested thread (can be GuildText) + * @returns true if channel exists, false otherwise */ -export interface Configuration { - readonly name: string - options: UserConfiguration | ServerConfiguration +async function checkChannelInfoExists(channel: TextChannel, user: string) { + const doesExists: boolean = await new Promise((resolve) => { + getChannelInfo(`${channel.id}-${user}.json`, (channelInfo) => { + if (channelInfo?.messages) { + resolve(true) + } else { + resolve(false) + } + }) + }) + return doesExists } /** - * User config to use outside of this file + * Method to clear channel history for requesting user + * + * @param filename guild id string + * @param channel the TextChannel in the Guild + * @param user username or ID of user + * @returns true if history was cleared, false if already empty or not found */ -export interface UserConfig { - readonly name: string - options: UserConfiguration -} +export async function clearChannelInfo(filename: string, channel: TextChannel, user: string): Promise { + const channelInfoExists: boolean = await checkChannelInfoExists(channel, user) -export interface ServerConfig { - readonly name: string - options: ServerConfiguration -} + // If thread does not exist, file can't be found + if (!channelInfoExists) return false -export interface Channel { - readonly id: string - readonly name: string - readonly user: string - messages: UserMessage[] + // Attempt to clear user channel history + const fullFileName = path.join('/app/data', `${filename}-${user}.json`) + try { + const data = await fs.readFile(fullFileName, 'utf8') + const object = JSON.parse(data) + if (object['messages'].length === 0) { + console.log(`[Util: clearChannelInfo] History already empty for ${fullFileName}`) + return false + } + object['messages'] = [] + await fs.writeFile(fullFileName, JSON.stringify(object, null, 2), { flag: 'w', mode: 0o600 }) + console.log(`[Util: clearChannelInfo] Cleared history for ${fullFileName}`) + return true + } catch (error) { + console.log(`[Util: clearChannelInfo] Failed to clear ${fullFileName}: ${error}`) + return false + } } /** - * The following 2 types is allow for better readability in commands - * Admin Command -> Don't run in Threads - * User Command -> Used anywhere + * Method to open the channel history + * + * @param filename name of the json file for the channel by user (without path) + * @param channel the text channel info + * @param user the user's ID or clientId for bots + * @param messages their messages */ -export const AdminCommand = [ - ChannelType.GuildText -] - -export const UserCommand = [ - ChannelType.GuildText, - ChannelType.PublicThread, - ChannelType.PrivateThread -] +export async function openChannelInfo(filename: string, channel: TextChannel | ThreadChannel, user: string, messages: UserMessage[] = []): Promise { + const fullFileName = path.join('/app/data', `${filename}-${user}.json`) + console.log(`[Util: openChannelInfo] Attempting to create/open ${fullFileName}`) + try { + if (await fs.access(fullFileName).then(() => true).catch(() => false)) { + const data = await fs.readFile(fullFileName, 'utf8') + const object = JSON.parse(data) + if (messages.length > 0) { + object['messages'] = messages + } + await fs.writeFile(fullFileName, JSON.stringify(object, null, 2), { flag: 'w', mode: 0o600 }) + console.log(`[Util: openChannelInfo] Updated ${fullFileName}`) + } else { + const object: Channel = { + id: channel?.id || filename, + name: channel?.name || 'unknown', + user, + messages: messages + } + const directory = path.dirname(fullFileName) + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(fullFileName, JSON.stringify(object, null, 2), { flag: 'w', mode: 0o600 }) + console.log(`[Util: openChannelInfo] Created '${fullFileName}' in working directory`) + } + } catch (error) { + console.log(`[Util: openChannelInfo] Failed to write ${fullFileName}: ${error}`) + throw error + } +} /** - * Check if the configuration we are editing/taking from is a Server Config - * @param key name of command we ran - * @returns true if command is from Server Config, false otherwise + * Method to get the channel information/history + * + * @param filename name of the json file for the channel by user (without path) + * @param callback function to handle resolving message history */ -export function isServerConfigurationKey(key: string): key is keyof ServerConfiguration { - return ['toggle-chat'].includes(key); -} \ No newline at end of file +export async function getChannelInfo(filename: string, callback: (config: Channel | undefined) => void): Promise { + const fullFileName = path.join('/app/data', filename) + console.log(`[Util: getChannelInfo] Reading ${fullFileName}`) + try { + const data = await fs.readFile(fullFileName, 'utf8') + const config = JSON.parse(data) + if (!config || !Array.isArray(config.messages)) { + console.log(`[Util: getChannelInfo] Invalid or empty config in ${fullFileName}: ${JSON.stringify(config)}`) + callback(undefined) + } else { + console.log(`[Util: getChannelInfo] Successfully read ${fullFileName}`) + callback(config) + } + } catch (error) { + console.log(`[Util: getChannelInfo] Failed to read ${fullFileName}: ${error}`) + callback(undefined) + } +} diff --git a/src/utils/handlers/chatHistoryHandler.ts b/src/utils/handlers/chatHistoryHandler.ts index 186e342..61f5fdf 100644 --- a/src/utils/handlers/chatHistoryHandler.ts +++ b/src/utils/handlers/chatHistoryHandler.ts @@ -1,21 +1,22 @@ import { TextChannel, ThreadChannel } from 'discord.js' -import { Configuration, Channel, UserMessage } from '../index.js' -import fs from 'fs' +import { Channel, UserMessage } from '../index.js' +import fs from 'fs/promises' import path from 'path' /** * Method to check if a thread history file exists * * @param channel parent thread of the requested thread (can be GuildText) - * @returns true if channel does not exist, false otherwise + * @returns true if channel exists, false otherwise */ async function checkChannelInfoExists(channel: TextChannel, user: string) { const doesExists: boolean = await new Promise((resolve) => { getChannelInfo(`${channel.id}-${user}.json`, (channelInfo) => { - if (channelInfo?.messages) + if (channelInfo?.messages) { resolve(true) - else + } else { resolve(false) + } }) }) return doesExists @@ -26,8 +27,8 @@ async function checkChannelInfoExists(channel: TextChannel, user: string) { * * @param filename guild id string * @param channel the TextChannel in the Guild - * @param user username of user - * @returns nothing + * @param user username or ID of user + * @returns true if history was cleared, false if already empty or not found */ export async function clearChannelInfo(filename: string, channel: TextChannel, user: string): Promise { const channelInfoExists: boolean = await checkChannelInfoExists(channel, user) @@ -36,86 +37,83 @@ export async function clearChannelInfo(filename: string, channel: TextChannel, u if (!channelInfoExists) return false // Attempt to clear user channel history - const fullFileName = `data/${filename}-${user}.json` - const cleanedHistory: boolean = await new Promise((resolve) => { - fs.readFile(fullFileName, 'utf8', (error, data) => { - if (error) - console.log(`[Error: openChannelInfo] Incorrect file format`) - else { - const object = JSON.parse(data) - if (object['messages'].length === 0) // already empty, let user know - resolve(false) - else { - object['messages'] = [] // cleared history - fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2)) - resolve(true) - } - } - }) - }) - return cleanedHistory + const fullFileName = path.join('/app/data', `${filename}-${user}.json`) + try { + const data = await fs.readFile(fullFileName, 'utf8') + const object = JSON.parse(data) + if (object['messages'].length === 0) { + console.log(`[Util: clearChannelInfo] History already empty for ${fullFileName}`) + return false + } + object['messages'] = [] + await fs.writeFile(fullFileName, JSON.stringify(object, null, 2), { flag: 'w', mode: 0o600 }) + console.log(`[Util: clearChannelInfo] Cleared history for ${fullFileName}`) + return true + } catch (error) { + console.log(`[Util: clearChannelInfo] Failed to clear ${fullFileName}: ${error}`) + return false + } } /** * Method to open the channel history * - * @param filename name of the json file for the channel by user + * @param filename name of the json file for the channel by user (without path) * @param channel the text channel info - * @param user the user's name + * @param user the user's ID or clientId for bots * @param messages their messages */ export async function openChannelInfo(filename: string, channel: TextChannel | ThreadChannel, user: string, messages: UserMessage[] = []): Promise { - const fullFileName = `data/${filename}-${user}.json` - if (fs.existsSync(fullFileName)) { - fs.readFile(fullFileName, 'utf8', (error, data) => { - if (error) - console.log(`[Error: openChannelInfo] Incorrect file format`) - else { - const object = JSON.parse(data) - if (object['messages'].length === 0) - object['messages'] = messages as [] - else if (object['messages'].length !== 0 && messages.length !== 0) - object['messages'] = messages as [] - fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2)) + const fullFileName = path.join('/app/data', `${filename}-${user}.json`) + console.log(`[Util: openChannelInfo] Attempting to create/open ${fullFileName}`) + try { + if (await fs.access(fullFileName).then(() => true).catch(() => false)) { + const data = await fs.readFile(fullFileName, 'utf8') + const object = JSON.parse(data) + if (messages.length > 0) { + object['messages'] = messages } - }) - } else { // file doesn't exist, create it - const object: Configuration = JSON.parse( - `{ - \"id\": \"${channel?.id}\", - \"name\": \"${channel?.name}\", - \"user\": \"${user}\", - \"messages\": [] - }` - ) - - const directory = path.dirname(fullFileName) - if (!fs.existsSync(directory)) - fs.mkdirSync(directory, { recursive: true }) - - // only creating it, no need to add anything - fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2)) - console.log(`[Util: openChannelInfo] Created '${fullFileName}' in working directory`) + await fs.writeFile(fullFileName, JSON.stringify(object, null, 2), { flag: 'w', mode: 0o600 }) + console.log(`[Util: openChannelInfo] Updated ${fullFileName}`) + } else { + const object: Channel = { + id: channel?.id || filename, + name: channel?.name || 'unknown', + user, + messages: messages + } + const directory = path.dirname(fullFileName) + await fs.mkdir(directory, { recursive: true }) + await fs.writeFile(fullFileName, JSON.stringify(object, null, 2), { flag: 'w', mode: 0o600 }) + console.log(`[Util: openChannelInfo] Created '${fullFileName}' in working directory`) + } + } catch (error) { + console.log(`[Util: openChannelInfo] Failed to write ${fullFileName}: ${error}`) + throw error } } /** * Method to get the channel information/history * - * @param filename name of the json file for the channel by user + * @param filename name of the json file for the channel by user (without path) * @param callback function to handle resolving message history */ export async function getChannelInfo(filename: string, callback: (config: Channel | undefined) => void): Promise { - const fullFileName = `data/${filename}` - if (fs.existsSync(fullFileName)) { - fs.readFile(fullFileName, 'utf8', (error, data) => { - if (error) { - callback(undefined) - return // something went wrong... stop - } - callback(JSON.parse(data)) - }) - } else { - callback(undefined) // file not found + const fullFileName = path.join('/app/data', filename) + console.log(`[Util: getChannelInfo] Reading ${fullFileName}`) + try { + const data = await fs.readFile(fullFileName, 'utf8') + const config = JSON.parse(data) + if (!config || !Array.isArray(config.messages)) { + console.log(`[Util: getChannelInfo] Invalid or empty config in ${fullFileName}: ${JSON.stringify(config)}`) + callback(undefined) + } else { + console.log(`[Util: getChannelInfo] Successfully read ${fullFileName}`) + callback(config) + } + } catch (error) { + console.log(`[Util: getChannelInfo] Failed to read ${fullFileName}: ${error}`) + callback(undefined) } -} \ No newline at end of file +} diff --git a/src/utils/handlers/configHandler.ts b/src/utils/handlers/configHandler.ts index b347c74..8fdbc79 100644 --- a/src/utils/handlers/configHandler.ts +++ b/src/utils/handlers/configHandler.ts @@ -1,92 +1,62 @@ -import { Configuration, ServerConfig, UserConfig, isServerConfigurationKey } from '../index.js' -import fs from 'fs' +import fs from 'fs/promises' import path from 'path' +import { ServerConfig, UserConfig } from '../index.js' -/** - * Method to open a file in the working directory and modify/create it - * - * @param filename name of the file - * @param key key value to access - * @param value new value to assign - */ -// add type of change (server, user) -export function openConfig(filename: string, key: string, value: any) { - const fullFileName = `data/${filename}` - - // check if the file exists, if not then make the config file - if (fs.existsSync(fullFileName)) { - fs.readFile(fullFileName, 'utf8', (error, data) => { - if (error) - console.log(`[Error: openConfig] Incorrect file format`) - else { - const object = JSON.parse(data) - object['options'][key] = value - fs.writeFileSync(fullFileName, JSON.stringify(object, null, 2)) - } - }) - } else { // work on dynamic file creation - let object: Configuration - if (isServerConfigurationKey(key)) - object = JSON.parse('{ \"name\": \"Server Confirgurations\" }') - else - object = JSON.parse('{ \"name\": \"User Confirgurations\" }') - - // set standard information for config file and options - object['options'] = { - [key]: value +export async function openConfig(fileName: string, option: string, value: any) { + const filePath = path.join('/app/data', fileName) + console.log(`[Util: openConfig] Creating/opening ${filePath}`) + try { + let config = { name: fileName.replace('-config.json', ''), options: {} } + try { + const data = await fs.readFile(filePath, 'utf-8') + config = JSON.parse(data) + if (!config.options) config.options = {} + } catch (error) { + console.log(`[Util: openConfig] No existing config at ${filePath}, creating new`) } - - const directory = path.dirname(fullFileName) - if (!fs.existsSync(directory)) - fs.mkdirSync(directory, { recursive: true }) - - fs.writeFileSync(`data/${filename}`, JSON.stringify(object, null, 2)) - console.log(`[Util: openConfig] Created '${filename}' in working directory`) + config.options[option] = value + await fs.writeFile(filePath, JSON.stringify(config, null, 2), { flag: 'w', mode: 0o600 }) + console.log(`[Util: openConfig] Successfully wrote ${filePath} with ${option}=${value}`) + } catch (error) { + console.log(`[Util: openConfig] Failed to write ${filePath}: ${error}`) + throw error } } -/** - * Method to obtain the configurations of the message chat/thread - * - * @param filename name of the configuration file to get - * @param callback function to allow a promise from getting the config - */ -export async function getServerConfig(filename: string, callback: (config: ServerConfig | undefined) => void): Promise { - const fullFileName = `data/${filename}` - - // attempt to read the file and get the configuration - if (fs.existsSync(fullFileName)) { - fs.readFile(fullFileName, 'utf8', (error, data) => { - if (error) { - callback(undefined) - return // something went wrong... stop - } - callback(JSON.parse(data)) - }) - } else { - callback(undefined) // file not found +export async function getServerConfig(fileName: string, callback: (config: ServerConfig | undefined) => void) { + const filePath = path.join('/app/data', fileName) + console.log(`[Util: getServerConfig] Reading ${filePath}`) + try { + const data = await fs.readFile(filePath, 'utf-8') + const config = JSON.parse(data) + if (!config || !config.options || !config.name) { + console.log(`[Util: getServerConfig] Invalid or empty config in ${filePath}: ${JSON.stringify(config)}`) + callback({ name: fileName.replace('-config.json', ''), options: { 'toggle-chat': true } }) + } else { + console.log(`[Util: getServerConfig] Successfully read ${filePath}`) + callback(config) + } + } catch (error) { + console.log(`[Util: getServerConfig] Failed to read ${filePath}: ${error}`) + callback({ name: fileName.replace('-config.json', ''), options: { 'toggle-chat': true } }) } } -/** - * Method to obtain the configurations of the message chat/thread - * - * @param filename name of the configuration file to get - * @param callback function to allow a promise from getting the config - */ -export async function getUserConfig(filename: string, callback: (config: UserConfig | undefined) => void): Promise { - const fullFileName = `data/${filename}` - - // attempt to read the file and get the configuration - if (fs.existsSync(fullFileName)) { - fs.readFile(fullFileName, 'utf8', (error, data) => { - if (error) { - callback(undefined) - return // something went wrong... stop - } - callback(JSON.parse(data)) - }) - } else { - callback(undefined) // file not found +export async function getUserConfig(fileName: string, callback: (config: UserConfig | undefined) => void) { + const filePath = path.join('/app/data', fileName) + console.log(`[Util: getUserConfig] Reading ${filePath}`) + try { + const data = await fs.readFile(filePath, 'utf-8') + const config = JSON.parse(data) + if (!config || !config.options || !config.name) { + console.log(`[Util: getUserConfig] Invalid or empty config in ${filePath}: ${JSON.stringify(config)}`) + callback(undefined) + } else { + console.log(`[Util: getUserConfig] Successfully read ${filePath}`) + callback(config) + } + } catch (error) { + console.log(`[Util: getUserConfig] Failed to read ${filePath}: ${error}`) + callback(undefined) } } diff --git a/src/utils/index.ts b/src/utils/index.ts index 13efca8..3060364 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -1,13 +1,22 @@ +// src/utils/index.ts // Centralized import index export * from './env.js' export * from './events.js' -export * from './messageNormal.js' export * from './commands.js' -export * from './configInterfaces.js' export * from './mentionClean.js' -// handler imports -export * from './handlers/chatHistoryHandler.js' +// Explicitly re-export messageNormal members +export { normalMessage, UserMessage } from './messageNormal.js' + +// Explicitly re-export configInterfaces members +export { UserConfig, ServerConfig, Channel } from './configInterfaces.js' + +// Explicitly re-export handler functions +export { + clearChannelInfo, + getChannelInfo, + openChannelInfo +} from './handlers/chatHistoryHandler.js' export * from './handlers/configHandler.js' export * from './handlers/streamHandler.js' export * from './handlers/bufferHandler.js' diff --git a/src/utils/messageNormal.ts b/src/utils/messageNormal.ts index a438e41..7807039 100644 --- a/src/utils/messageNormal.ts +++ b/src/utils/messageNormal.ts @@ -1,9 +1,36 @@ import { Message, SendableChannels } from 'discord.js' import { ChatResponse, Ollama } from 'ollama' -import { ChatParams, UserMessage, streamResponse, blockResponse } from './index.js' import { Queue } from '../queues/queue.js' import { AbortableAsyncIterator } from 'ollama/src/utils.js' +export interface UserMessage { + role: 'user' | 'assistant' + content: string + images?: string[] +} + +export interface ChatParams { + model: string + ollama: Ollama + msgHist: UserMessage[] +} + +export async function streamResponse(params: ChatParams): Promise> { + return params.ollama.chat({ + model: params.model, + messages: params.msgHist.map(msg => ({ role: msg.role, content: msg.content, images: msg.images })), + stream: true + }) +} + +export async function blockResponse(params: ChatParams): Promise { + return params.ollama.chat({ + model: params.model, + messages: params.msgHist.map(msg => ({ role: msg.role, content: msg.content, images: msg.images })), + stream: false + }) +} + /** * Method to send replies as normal text on discord like any other user * @param message message sent by the user @@ -17,70 +44,58 @@ export async function normalMessage( msgHist: Queue, stream: boolean ): Promise { - // bot's respnse - let response: ChatResponse | AbortableAsyncIterator let result: string = '' const channel = message.channel as SendableChannels - await channel.send('Generating Response . . .').then(async sentMessage => { - try { - const params: ChatParams = { - model: model, - ollama: ollama, - msgHist: msgHist.getItems() - } - - // run query based on stream preference, true = stream, false = block - if (stream) { - let messageBlock: Message = sentMessage - response = await streamResponse(params) // THIS WILL BE SLOW due to discord limits! - for await (const portion of response) { - // check if over discord message limit - if (result.length + portion.message.content.length > 2000) { - result = portion.message.content - - // new message block, wait for it to send and assign new block to respond. - await channel.send("Creating new stream block...") - .then(sentMessage => { messageBlock = sentMessage }) - } else { - result += portion.message.content - - // ensure block is not empty - if (result.length > 5) - messageBlock.edit(result) - } - console.log(result) - } - } - else { - response = await blockResponse(params) - result = response.message.content - - // check if message length > discord max for normal messages - if (result.length > 2000) { - sentMessage.edit(result.slice(0, 2000)) - result = result.slice(2000) - - // handle for rest of message that is >2000 - while (result.length > 2000) { - channel.send(result.slice(0, 2000)) - result = result.slice(2000) - } - - // last part of message - channel.send(result) - } else // edit the 'generic' response to new message since <2000 - sentMessage.edit(result) - } - } catch (error: any) { - console.log(`[Util: messageNormal] Error creating message: ${error.message}`) - if (error.message.includes('try pulling it first')) - sentMessage.edit(`**Response generation failed.**\n\nReason: You do not have the ${model} downloaded. Ask an admin to pull it using the \`pull-model\` command.`) - else - sentMessage.edit(`**Response generation failed.**\n\nReason: ${error.message}`) + const sentMessage = await channel.send('Generating Response . . .') + try { + const params: ChatParams = { + model: model, + ollama: ollama, + msgHist: msgHist.getItems() } - }) - // return the string representation of ollama query response + if (stream) { + let messageBlock = sentMessage + const response = await streamResponse(params) + for await (const portion of response) { + if (result.length + portion.message.content.length > 2000) { + result = portion.message.content + messageBlock = await channel.send("Creating new stream block...") + } else { + result += portion.message.content + if (result.length > 5) { + await messageBlock.edit(result) + } + } + console.log(result) + } + } else { + const response = await blockResponse(params) + result = response.message.content + + if (result.length > 2000) { + await sentMessage.edit(result.slice(0, 2000)) + result = result.slice(2000) + while (result.length > 2000) { + await channel.send(result.slice(0, 2000)) + result = result.slice(2000) + } + await channel.send(result) + } else { + await sentMessage.edit(result) + } + } + } catch (error: any) { + console.log(`[Util: normalMessage] Error creating message: ${error.message}`) + let errorMessage = '**Response generation failed.**\n\nReason: '; + if (error.message.includes('try pulling it first')) { + errorMessage += `You do not have the ${model} downloaded. Ask an admin to pull it using the \`pull-model\` command.`; + } else { + errorMessage += error.message; + } + await sentMessage.edit(errorMessage); + } + return result }