From 9ba1c9510e689cac8201baedc08d36ebbd250588 Mon Sep 17 00:00:00 2001 From: LunaUrsa <1836049+LunaUrsa@users.noreply.github.com> Date: Mon, 13 Nov 2023 14:09:07 -0600 Subject: [PATCH 1/4] Testing --- src/discord/commands/guild/d.ai.ts | 452 +++++++++++++++++++--------- src/discord/events/messageCreate.ts | 4 +- src/discord/utils/messageCommand.ts | 8 +- src/global/commands/g.ai.ts | 282 ++--------------- src/global/utils/env.config.ts | 1 + 5 files changed, 331 insertions(+), 416 deletions(-) diff --git a/src/discord/commands/guild/d.ai.ts b/src/discord/commands/guild/d.ai.ts index 95cdc1561..19e104f97 100644 --- a/src/discord/commands/guild/d.ai.ts +++ b/src/discord/commands/guild/d.ai.ts @@ -35,17 +35,10 @@ import { Moderation } from 'openai/resources'; import { paginationEmbed } from '../../utils/pagination'; import { SlashCommand } from '../../@types/commandDef'; import { embedTemplate } from '../../utils/embedTemplate'; -import { - aiSet, - aiGet, - aiDel, - aiLink, - aiChat, - aiModerate, -} from '../../../global/commands/g.ai'; import commandContext from '../../utils/context'; import { userInfoEmbed } from '../../../global/commands/g.moderate'; import { sleep } from './d.bottest'; +import aiChat, { aiModerate } from '../../../global/commands/g.ai'; const db = new PrismaClient({ log: ['error'] }); @@ -54,6 +47,7 @@ const F = f(__filename); const maxHistoryLength = 5; const ephemeralExplanation = 'Set to "True" to show the response only to you'; +const personaDoesntExist = 'This persona does not exist. Please create it first.'; const confirmationCodes = new Map(); const tripbotUAT = '@TripBot UAT (Moonbear)'; @@ -301,21 +295,27 @@ async function set( created_at: existingPersona ? existingPersona.created_at : new Date(), } as ai_personas; - const response = await aiSet(aiPersona); + const alreadyExists = await db.ai_personas.findFirst({ + where: { + name: aiPersona.name, + }, + }); + const action = alreadyExists ? 'updated' : 'created'; - if (response.startsWith('Success')) { - const personaEmbed = await makePersonaEmbed(aiPersona); - await i.editReply({ - embeds: [personaEmbed], - }); - } else { - await i.editReply({ - embeds: [embedTemplate() - .setTitle('Modal') - .setColor(Colors.Red) - .setDescription(response)], - }); - } + await db.ai_personas.upsert({ + where: { + name: aiPersona.id, + }, + create: aiPersona, + update: aiPersona, + }); + + await i.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription(`Success! This persona has been ${action}!`)], + }); }); } @@ -327,30 +327,32 @@ async function get( const modelName = interaction.options.getString('name'); const channel = interaction.options.getChannel('channel') ?? interaction.channel; - let aiPersona = '' as string | ai_personas; + let aiPersona:ai_personas | null = null; let description = '' as string; if (modelName) { - aiPersona = await aiGet(modelName); + aiPersona = await db.ai_personas.findUnique({ + where: { + name: modelName, + }, + }); } else if (channel) { // Check if the channel is linked to a persona - let aiLinkData = {} as { - id: string; - channel_id: string; - persona_id: string; - } | null; - aiLinkData = await db.ai_channels.findFirst({ + let aiLinkData = await db.ai_channels.findFirst({ where: { channel_id: channel.id, }, }); if (aiLinkData) { log.debug(F, `Found aiLinkData on first go: ${JSON.stringify(aiLinkData, null, 2)}`); - aiPersona = await db.ai_personas.findUniqueOrThrow({ + aiPersona = await db.ai_personas.findUnique({ where: { id: aiLinkData.persona_id, }, - }) as ai_personas; - description = `Channel ${(channel as TextChannel).name} is linked with the **"${aiPersona.name ?? aiPersona}"** persona:`; + }); + if (aiPersona) { + // eslint-disable-next-line max-len + description = `Channel ${(channel as TextChannel).name} is linked with the **"${aiPersona.name ?? aiPersona}"** persona:`; + } } if (!aiLinkData && (channel as ThreadChannel).parent) { @@ -362,13 +364,15 @@ async function get( }, }); if (aiLinkData) { - aiPersona = await db.ai_personas.findUniqueOrThrow({ + aiPersona = await db.ai_personas.findUnique({ where: { id: aiLinkData.persona_id, }, - }) as ai_personas; + }); + if (aiPersona) { // eslint-disable-next-line max-len - description = `Channel ${(channel as ThreadChannel).parent} is linked with the **"${aiPersona.name}"** persona:`; + description = `Parent category/channel ${(channel as ThreadChannel).parent} is linked with the **"${aiPersona.name}"** persona:`; + } } } @@ -381,20 +385,22 @@ async function get( }, }); if (aiLinkData) { - aiPersona = await db.ai_personas.findUniqueOrThrow({ + aiPersona = await db.ai_personas.findUnique({ where: { id: aiLinkData.persona_id, }, - }) as ai_personas; + }); + if (aiPersona) { // eslint-disable-next-line max-len - description = `Category ${(channel as ThreadChannel).parent?.parent} is linked with the **"${aiPersona.name}"** persona:`; + description = `Parent category ${(channel as ThreadChannel).parent?.parent} is linked with the **"${aiPersona.name}"** persona:`; + } } } } log.debug(F, `aiPersona: ${JSON.stringify(aiPersona, null, 2)}`); - if (typeof aiPersona === 'string') { + if (!aiPersona) { await interaction.editReply({ embeds: [embedTemplate() .setTitle('Modal') @@ -424,7 +430,11 @@ async function del( const personaName = interaction.options.getString('name') ?? interaction.user.username; if (!confirmation) { - const aiPersona = await aiGet(personaName); + const aiPersona = await db.ai_personas.findUnique({ + where: { + name: personaName, + }, + }); if (!aiPersona) { await interaction.editReply({ @@ -470,57 +480,40 @@ async function del( return; } - const response = await aiDel(personaName); - log.debug(F, `response: ${response}`); - - if (!response.startsWith('Success')) { - await interaction.editReply({ - embeds: [embedTemplate() - .setTitle('Modal') - .setColor(Colors.Red) - .setDescription(response)], - - }); - return; - } + await db.ai_personas.delete({ + where: { + name: personaName, + }, + }); confirmationCodes.delete(`${interaction.user.id}${interaction.user.username}`); await interaction.editReply({ embeds: [embedTemplate() .setTitle('Modal') .setColor(Colors.Blurple) - .setDescription(response)], + .setDescription('Success: Persona was deleted!')], }); } async function getLinkedChannel( channel: CategoryChannel | ForumChannel | APIInteractionDataResolvedChannel | TextBasedChannel, ):Promise { + // With the way AI personas work, they can be assigned to a category, channel, or thread + // This function will check if the given channel is linked to an AI persona + // If it is not, it will check the channel's parent; either the Category or Channel (in case of Thread) + // If the parent isn't linked, it'll check the parent's parent; this is only for Thread channels. + // Once a link is fount, it will return that link data + // If no link is found, it'll return null + // Check if the channel is linked to a persona - let aiLinkData = await db.ai_channels.findFirst({ - where: { - channel_id: channel.id, - }, - }); + let aiLinkData = await db.ai_channels.findFirst({ where: { channel_id: channel.id } }); // If the channel isn't listed in the database, check the parent - if (!aiLinkData - && 'parent' in channel - && channel.parent) { - aiLinkData = await db.ai_channels.findFirst({ - where: { - channel_id: channel.parent.id, - }, - }); - // If /that/ channel doesn't exist, check the parent of the parent - // This is mostly for threads - if (!aiLinkData - && channel.parent.parent) { - aiLinkData = await db.ai_channels.findFirst({ - where: { - channel_id: channel.parent.parent.id, - }, - }); + if (!aiLinkData && 'parent' in channel && channel.parent) { + aiLinkData = await db.ai_channels.findFirst({ where: { channel_id: channel.parent.id } }); + // If /that/ channel doesn't exist, check the parent of the parent, this is for threads + if (!aiLinkData && channel.parent.parent) { + aiLinkData = await db.ai_channels.findFirst({ where: { channel_id: channel.parent.parent.id } }); } } @@ -535,10 +528,10 @@ async function link( const personaName = interaction.options.getString('name') ?? interaction.user.username; const toggle = (interaction.options.getString('toggle') ?? 'enable') as 'enable' | 'disable'; + const textChannel = interaction.options.getChannel('channel') ?? interaction.channel; - let response = '' as string; + const response = '' as string; if (toggle === 'enable') { - const textChannel = interaction.options.getChannel('channel') ?? interaction.channel; if (!textChannel) { await interaction.editReply({ embeds: [embedTemplate() @@ -549,35 +542,217 @@ async function link( }); return; } - response = await aiLink(personaName, textChannel.id, toggle); - } else { - const textChannel = interaction.options.getChannel('channel'); - if (textChannel) { - response = await aiLink(personaName, textChannel.id, toggle); - } else if (interaction.channel) { - const aiLinkData = await getLinkedChannel(interaction.channel); - if (aiLinkData) { - response = await aiLink(personaName, aiLinkData?.channel_id, toggle); - } else { + // response = await aiLink(personaName, textChannel.id, toggle); + + const personaData = await db.ai_personas.findUnique({ + where: { + name: personaName, + }, + }); + + if (!personaData) { + await interaction.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription(personaDoesntExist)], + + }); + return; + } + + const aiLinkData = await db.ai_channels.findFirst({ + where: { + channel_id: textChannel.id, + }, + }); + + if (aiLinkData) { + await db.ai_channels.update({ + where: { + id: aiLinkData.id, + }, + data: { + channel_id: textChannel.id, + persona_id: personaData.id, + }, + }); + await interaction.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription(`Success: The link between ${personaName} and <#${textChannel.id}> was updated!`)], + + }); + return; + } + + await db.ai_channels.create({ + data: { + channel_id: textChannel.id, + persona_id: personaData.id, + }, + }); + await interaction.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription(`Success: The link between ${personaName} and <#${textChannel.id}> was created!`)], + + }); + return; + } + + if (textChannel) { + // response = await aiLink(personaName, textChannel.id, toggle); + const existingPersona = await db.ai_personas.findUnique({ + where: { + name: personaName, + }, + }); + + if (!existingPersona) { + await interaction.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription(personaDoesntExist)], + + }); + return; + } + + let existingLink = await db.ai_channels.findFirst({ + where: { + channel_id: textChannel.id, + persona_id: existingPersona.id, + }, + }); + + if (!existingLink) { + existingLink = await db.ai_channels.findFirst({ + where: { + channel_id: textChannel.id, + }, + }); + + if (!existingLink) { + await interaction.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription(`Error: No link to <#${textChannel.id}> found!`)], + + }); + return; + } + const personaData = await db.ai_personas.findUnique({ + where: { + id: existingLink.persona_id, + }, + }); + if (!personaData) { + await interaction.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription('Error: No persona found for this link!')], + + }); + return; + } + await db.ai_channels.delete({ + where: { + id: existingLink.id, + }, + }); + } + } else if (interaction.channel) { + const aiLinkData = await getLinkedChannel(interaction.channel); + if (aiLinkData) { + // response = await aiLink(personaName, aiLinkData?.channel_id, toggle); + + const existingPersona = await db.ai_personas.findUnique({ + where: { + name: personaName, + }, + }); + + if (!existingPersona) { await interaction.editReply({ embeds: [embedTemplate() .setTitle('Modal') .setColor(Colors.Red) - .setDescription('This channel is not linked to an AI persona.')], + .setDescription(personaDoesntExist)], }); return; } + + let existingLink = await db.ai_channels.findFirst({ + where: { + channel_id: aiLinkData.channel_id, + persona_id: existingPersona.id, + }, + }); + + if (!existingLink) { + existingLink = await db.ai_channels.findFirst({ + where: { + channel_id: aiLinkData.channel_id, + }, + }); + + if (!existingLink) { + await interaction.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription(`Error: No link to <#${aiLinkData.channel_id}> found!`)], + + }); + return; + } + const personaData = await db.ai_personas.findUnique({ + where: { + id: existingLink.persona_id, + }, + }); + if (!personaData) { + await interaction.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription('Error: No persona found for this link!')], + + }); + return; + } + await db.ai_channels.delete({ + where: { + id: existingLink.id, + }, + }); + } } else { await interaction.editReply({ embeds: [embedTemplate() .setTitle('Modal') .setColor(Colors.Red) - .setDescription('You must provide a text channel to link to.')], + .setDescription('This channel is not linked to an AI persona.')], }); return; } + } else { + await interaction.editReply({ + embeds: [embedTemplate() + .setTitle('Modal') + .setColor(Colors.Red) + .setDescription('You must provide a text channel to link to.')], + + }); + return; } log.debug(F, `response: ${response}`); @@ -603,7 +778,7 @@ async function link( } async function isVerifiedMember(message:Message):Promise { - if (!message.member) return false; + if (!message?.member) return false; return message.member?.roles.cache.has(env.ROLE_VERIFIED); } @@ -663,8 +838,8 @@ export async function aiAudit( }, ); - const promptCost = (promptTokens / 1000) * aiCosts[cleanPersona.ai_model as keyof typeof aiCosts].input; - const completionCost = (completionTokens / 1000) * aiCosts[cleanPersona.ai_model as keyof typeof aiCosts].output; + const promptCost = (promptTokens / 1000) * aiCosts[cleanPersona.ai_model].input; + const completionCost = (completionTokens / 1000) * aiCosts[cleanPersona.ai_model].output; // log.debug(F, `promptCost: ${promptCost}, completionCost: ${completionCost}`); embed.spliceFields( @@ -752,24 +927,24 @@ export async function aiAudit( * @param {Message} message The interaction that spawned this commend * @return {Promise} The response from the AI */ -export async function moderate( +export async function discordAiModerate( message:Message, ):Promise { - // Remove "TripBot UAT (Moonbear)" from the message - const cleanMessage = message.cleanContent + const [result] = await aiModerate(message.cleanContent .replace(tripbotUAT, '') - .replace('tripbot', ''); - // log.debug(F, `cleanMessage: ${cleanMessage}`); - - const [result] = await aiModerate(cleanMessage); - + .replace('tripbot', '')); await aiAudit(null, [message], null, result, 0, 0); } -export async function chat( - messages:Message[], +export async function discordAiChat( + messageData: Message, ):Promise { - // log.debug(F, `messages: ${JSON.stringify(messages, null, 2)}`); + log.debug(F, 'Started!'); + log.debug(F, `messageData: ${JSON.stringify(messageData.cleanContent, null, 2)}`); + const channelMessages = await messageData.channel.messages.fetch({ limit: 3 }); + log.debug(F, `channelMessages: ${JSON.stringify(channelMessages, null, 2)}`); + + const messages = [...channelMessages.values()]; if (!isVerifiedMember(messages[0])) return; if (messages[0].author.bot) return; @@ -778,11 +953,10 @@ export async function chat( // Check if the channel is linked to a persona const aiLinkData = await getLinkedChannel(messages[0].channel); - if (!aiLinkData) return; // log.debug(F, `aiLinkData: ${JSON.stringify(aiLinkData, null, 2)}`); - // Get persona details + // Get persona details for this channel, throw an error if the persona was deleted const aiPersona = await db.ai_personas.findUniqueOrThrow({ where: { id: aiLinkData.persona_id, @@ -790,66 +964,44 @@ export async function chat( }); // log.debug(F, `aiPersona: ${aiPersona.name}`); - const inputMessages = [{ - role: 'system', - content: aiPersona.prompt, - }] as OpenAI.Chat.CreateChatCompletionRequestMessage[]; - const cleanMessages = [] as Message[]; - // Get the last 3 messages that are not empty or from other bots - messages.forEach(message => { - if (message.cleanContent.length > 0 - && cleanMessages.length < maxHistoryLength) { // +2 for the prompt and the system message - cleanMessages.push(message); - } - }); - - cleanMessages.reverse(); // So that the first messages come first - cleanMessages.forEach(message => { - inputMessages.push({ - role: message.author.bot ? 'assistant' : 'user', + const messageList = messages + .filter(message => message.cleanContent.length > 0 && !message.author.bot) + .map(message => ({ + role: 'user', content: message.cleanContent .replace(tripbotUAT, '') .replace('tripbot', '') .trim(), - }); - }); + })) + .reverse() + .slice(0, maxHistoryLength) as OpenAI.Chat.ChatCompletionMessageParam[]; - const result = await aiChat(aiPersona, inputMessages); + const cleanMessageList = messages + .filter(message => message.cleanContent.length > 0 && !message.author.bot) + .reverse() + .slice(0, maxHistoryLength); + + const result = await aiChat(aiPersona, messageList); await aiAudit( aiPersona, - cleanMessages, + cleanMessageList, result.response, null, result.promptTokens, result.completionTokens, ); - try { - await messages[0].channel.sendTyping(); + await messages[0].channel.sendTyping(); - // Sleep for a bit to simulate typing in production - if (env.NODE_ENV === 'production') { - const wordCount = result.response.split(' ').length; - const sleepTime = Math.ceil(wordCount / 10); - await sleep(sleepTime * 1000); - } - await messages[0].reply(result.response.slice(0, 2000)); - } catch (e) { - log.error(F, `Error: ${e}`); - const channelAi = await discordClient.channels.fetch(env.CHANNEL_AILOG) as TextChannel; - await channelAi.send({ - content: `Hey <@${env.DISCORD_OWNER_ID}> I couldn't send a message to <#${messages[0].channel.id}>`, - embeds: [embedTemplate() - .setTitle('Error') - .setColor(Colors.Red) - .setDescription(stripIndents` - **Error:** ${e} - **Message:** ${result.response} - `)], - }); + // Sleep for a bit to simulate typing in production + if (env.NODE_ENV === 'production') { + // const wordCount = result.response.split(' ').length; + // const sleepTime = Math.ceil(wordCount / 10); + await sleep(10 * 1000); } + await messages[0].reply(result.response.slice(0, 2000)); } export const aiCommand: SlashCommand = { diff --git a/src/discord/events/messageCreate.ts b/src/discord/events/messageCreate.ts index cfb0798bf..b414e0f37 100644 --- a/src/discord/events/messageCreate.ts +++ b/src/discord/events/messageCreate.ts @@ -17,7 +17,7 @@ import { ExperienceCategory, ExperienceType } from '../../global/@types/database import { imagesOnly } from '../utils/imagesOnly'; import { countMessage } from '../commands/guild/d.counting'; import { bridgeMessage } from '../utils/bridge'; -// import { aiModerate } from '../utils/ai'; +import { discordAiModerate } from '../commands/guild/d.ai'; // import { awayMessage } from '../utils/awayMessage'; // import log from '../../global/utils/log'; // import {parse} from 'path'; @@ -80,7 +80,7 @@ export const messageCreate: MessageCreateEvent = { youAre(message); karma(message); imagesOnly(message); - // aiModerate(message); + discordAiModerate(message); // Disabled for testing // thoughtPolice(message); diff --git a/src/discord/utils/messageCommand.ts b/src/discord/utils/messageCommand.ts index 93844506c..2e885c7f5 100644 --- a/src/discord/utils/messageCommand.ts +++ b/src/discord/utils/messageCommand.ts @@ -8,7 +8,7 @@ import { } from 'discord.js'; import { stripIndents } from 'common-tags'; import { sleep } from '../commands/guild/d.bottest'; -import { chat } from '../commands/guild/d.ai'; +import { discordAiChat } from '../commands/guild/d.ai'; // import log from '../../global/utils/log'; // import {parse} from 'path'; @@ -255,11 +255,7 @@ give people a chance to answer 😄 If no one answers in 5 minutes you can try a } } } else if (await isAiEnabledGuild(message) && !message.author.bot) { - // log.debug(F, 'AI enabled guild detected'); - // Get the last 5 messages in the channel - const messages = await message.channel.messages.fetch({ limit: 10 }); - // log.debug(F, `messages: ${JSON.stringify(messages, null, 2)}`); - await chat([...messages.values()]); + await discordAiChat(message); } else { try { await message.react(emojiGet('ts_heart')); diff --git a/src/global/commands/g.ai.ts b/src/global/commands/g.ai.ts index ce990f174..45f65fc41 100644 --- a/src/global/commands/g.ai.ts +++ b/src/global/commands/g.ai.ts @@ -6,14 +6,16 @@ const db = new PrismaClient({ log: ['error'] }); const F = f(__filename); -const errorPersonaNotFound = 'Error: The requested persona does not exist!'; -export default aiChat; +const openai = new OpenAI({ + organization: 'org-h4Jvunqw3MmHmIgeLHpr1a3Y', + apiKey: env.OPENAI_API_KEY, +}); -// Objective truths are facts and dont impact personality +// Objective truths are facts and don't impact personality const objectiveTruths = { role: 'system', content: ` - Your name is TripBot, you on TripSit Discord. + Your name is TripBot, you are on TripSit Discord. You were born on Sept 26, 2011 on IRC and moved to discord in 2022. Your father is Moonbear and your mother is Reality. You remember IRC as a lawless place that you're not fond of, you're much happier on discord. @@ -27,7 +29,7 @@ const objectiveTruths = { Keep all responses under 2000 characters at maximum. `, -} as OpenAI.Chat.CreateChatCompletionRequestMessage; +} as OpenAI.Chat.ChatCompletionSystemMessageParam; // # Example dummy function hard coded to return the same weather // # In production, this could be your backend API or an external API @@ -58,227 +60,14 @@ const objectiveTruths = { // }, // ]; -/** - * Modifies a persona - * @return {Promise} The response from the AI - */ -export async function aiSet( - personaData: Omit, -):Promise { - log.debug(F, `personaData: ${JSON.stringify(personaData, null, 2)}`); - const existingPersona = await db.ai_personas.findUnique({ - where: { - name: personaData.name, - }, - }); - - if (!existingPersona) { - try { - await db.ai_personas.create({ - data: personaData, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error:any) { - log.error(F, `Error: ${error.message}`); - return `Error: ${error.message}`; - } - return 'Success! This persona has been created!'; - } - - try { - await db.ai_personas.update({ - where: { - name: personaData.name, - }, - data: personaData, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error:any) { - log.error(F, `Error: ${error.message}`); - return `Error: ${error.message}`; - } - return 'Success! This persona has been updated!'; -} - -/** - * Gets details on a persona - * @return {Promise} The response from the AI - */ -export async function aiGet( - name: string, -):Promise { - log.debug(F, `name: ${name}`); - - const existingPersona = await db.ai_personas.findUnique({ - where: { - name, - }, - }); - - if (!existingPersona) { - return errorPersonaNotFound; - } - - return existingPersona; -} - -/** - * Gets details on a persona - * @return {Promise} The response from the AI - */ -export async function aiGetAll():Promise { - return [] as ai_personas[]; -} - -/** - * Removes on a persona - * @return {Promise} The response from the AI - */ -export async function aiDel( - name: string, -):Promise { - log.debug(F, `name: ${name}`); - const existingPersona = await db.ai_personas.findUnique({ - where: { - name, - }, - }); - - if (!existingPersona) { - return errorPersonaNotFound; - } - - try { - await db.ai_personas.delete({ - where: { - name, - }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error:any) { - log.error(F, `Error: ${error.message}`); - return `Error: ${error.message}`; - } - return 'Success: Persona was deleted!'; -} - -/** - * LInks a persona with a channel - * @return {Promise} The response from the AI - */ -export async function aiLink( - name: ai_personas['name'], - channelId: string, - toggle: 'enable' | 'disable', -):Promise { - log.debug(F, `name: ${name}`); - log.debug(F, `channelId: ${channelId}`); - log.debug(F, `toggle: ${toggle}`); - - let personaName = name; - - const existingPersona = await db.ai_personas.findUnique({ - where: { - name, - }, - }); - - if (!existingPersona) { - return errorPersonaNotFound; - } - - if (toggle === 'disable') { - let existingLink = await db.ai_channels.findFirst({ - where: { - channel_id: channelId, - persona_id: existingPersona.id, - }, - }); - - if (!existingLink) { - existingLink = await db.ai_channels.findFirst({ - where: { - channel_id: channelId, - }, - }); - - if (!existingLink) { - return `Error: No link to <#${channelId}> found!`; - } - const personaData = await db.ai_personas.findUnique({ - where: { - id: existingLink.persona_id, - }, - }); - if (!personaData) { - return 'Error: No persona found for this link!'; - } - personaName = personaData.name; - } - - try { - await db.ai_channels.delete({ - where: { - id: existingLink.id, - }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error:any) { - log.error(F, `Error: ${error.message}`); - return `Error: ${error.message}`; - } - return `Success: The link between ${personaName} and <#${channelId}> was deleted!`; - } - - // Check if the channel is linked to a persona - const aiLinkData = await db.ai_channels.findFirst({ - where: { - channel_id: channelId, - }, - }); - - if (aiLinkData) { - try { - await db.ai_channels.update({ - where: { - id: aiLinkData.id, - }, - data: { - channel_id: channelId, - persona_id: existingPersona.id, - }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error:any) { - log.error(F, `Error: ${error.message}`); - return `Error: ${error.message}`; - } - return `Success: The link between ${name} and <#${channelId}> was updated!`; - } - - try { - await db.ai_channels.create({ - data: { - channel_id: channelId, - persona_id: existingPersona.id, - }, - }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } catch (error:any) { - log.error(F, `Error: ${error.message}`); - return `Error: ${error.message}`; - } - return `Success: The link between ${name} and <#${channelId}> was created!`; -} - /** * Sends an array of messages to the AI and returns the response * @param {Messages[]} messages A list of messages (chat history) to send * @return {Promise} The response from the AI */ -export async function aiChat( +export default async function aiChat( aiPersona:ai_personas, - messages: OpenAI.Chat.CreateChatCompletionRequestMessage[], + messages: OpenAI.Chat.ChatCompletionMessageParam [], ):Promise<{ response: string, promptTokens: number, @@ -297,41 +86,24 @@ export async function aiChat( model = aiPersona.ai_model.toLowerCase(); } - messages.unshift(objectiveTruths); - // // Go through the messages object, and find the object with the "system" role - // // add the objectiveTruths to that value - // const systemMessage = messages.find((message) => message.role === 'system') as ChatCompletionRequestMessage; - // let newMessage = systemMessage.content + objectiveTruths.content; - // if (systemMessage) { - // newMessage = objectiveTruths.content + systemMessage.content; - // log.debug(F, `messages: ${JSON.stringify(messages, null, 2)}`); - - const { - id, - name, - created_at, // eslint-disable-line @typescript-eslint/naming-convention - created_by, // eslint-disable-line @typescript-eslint/naming-convention - prompt, - logit_bias, // eslint-disable-line @typescript-eslint/naming-convention - total_tokens, // eslint-disable-line @typescript-eslint/naming-convention - ai_model, // eslint-disable-line @typescript-eslint/naming-convention - ...restOfAiPersona - } = aiPersona; + // This message list is sent to the API + const chatCompletionMessages = [{ + role: 'system', + content: aiPersona.prompt, + }] as OpenAI.Chat.ChatCompletionMessageParam[]; + chatCompletionMessages.unshift(objectiveTruths); + chatCompletionMessages.push(...messages); const payload = { - ...restOfAiPersona, + ...aiPersona, model, - messages, + messages: chatCompletionMessages, // functions: aiFunctions, // function_call: 'auto', - } as OpenAI.Chat.CompletionCreateParamsNonStreaming; + } as OpenAI.Chat.ChatCompletionCreateParamsNonStreaming; // log.debug(F, `payload: ${JSON.stringify(payload, null, 2)}`); - let responseMessage = {} as OpenAI.Chat.CreateChatCompletionRequestMessage; - const openai = new OpenAI({ - organization: 'org-h4Jvunqw3MmHmIgeLHpr1a3Y', - apiKey: env.OPENAI_API_KEY, - }); + let responseMessage = {} as OpenAI.Chat.ChatCompletionMessageParam; const chatCompletion = await openai.chat.completions .create(payload) .catch(err => { @@ -346,7 +118,7 @@ export async function aiChat( }); // log.debug(F, `chatCompletion: ${JSON.stringify(chatCompletion, null, 2)}`); - if (chatCompletion && chatCompletion.choices[0].message) { + if (chatCompletion?.choices[0].message) { responseMessage = chatCompletion.choices[0].message; // Sum up the existing tokens @@ -424,11 +196,6 @@ export async function aiChat( export async function aiModerate( message: string, ):Promise { - let results = [] as Moderation[]; - const openai = new OpenAI({ - organization: 'org-h4Jvunqw3MmHmIgeLHpr1a3Y', - apiKey: env.OPENAI_API_KEY, - }); const moderation = await openai.moderations .create({ input: message, @@ -443,9 +210,8 @@ export async function aiModerate( throw err; } }); - if (moderation && moderation.results) { - results = moderation.results; - // log.debug(F, `response: ${JSON.stringify(moderation.data.results, null, 2)}`); + if (!moderation) { + return []; } - return results; + return moderation.results; } diff --git a/src/global/utils/env.config.ts b/src/global/utils/env.config.ts index 9892e038c..f014e503b 100644 --- a/src/global/utils/env.config.ts +++ b/src/global/utils/env.config.ts @@ -21,6 +21,7 @@ export const env = { API_USERNAME: process.env.API_USERNAME, API_PASSWORD: process.env.API_PASSWORD, + OPENAI_API_ORG: process.env.OPENAI_API_ORG, OPENAI_API_KEY: process.env.OPENAI_API_KEY, IRC_USERNAME: 'TripBot', From ba56c9420d2acfd3b41aed6846b63ef748b706dc Mon Sep 17 00:00:00 2001 From: LunaUrsa <1836049+LunaUrsa@users.noreply.github.com> Date: Wed, 15 Nov 2023 14:02:22 -0600 Subject: [PATCH 2/4] Saving progress --- package.json | 3 +- src/discord/commands/guild/d.ai.ts | 1084 ++++++++++++++--- src/discord/events/autocomplete.ts | 11 +- src/discord/events/buttonClick.ts | 9 +- src/global/commands/g.ai.ts | 180 ++- src/global/utils/env.config.ts | 1 + .../tripbot/migrations/migration_lock.toml | 3 + src/prisma/tripbot/schema.prisma | 34 +- 8 files changed, 1088 insertions(+), 237 deletions(-) create mode 100644 src/prisma/tripbot/migrations/migration_lock.toml diff --git a/package.json b/package.json index 59f809768..535c9d60c 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,8 @@ "db:formatSchema": "docker exec -it tripbot npx prisma format", "db:validateSchema": "docker exec -it tripbot npx prisma validate", "db:generateClient": "npx prisma generate && docker exec -it tripbot npx prisma generate", - "db:migrateDev": "docker exec -it tripbot npx prisma migrate dev", + "db:push": "docker exec -it tripbot npx prisma db push", + "db:migrateDev": "docker exec -it tripbot npx prisma migrate dev -n changeName", "## PGADMIN ##": "", "pgadmin": "docker compose --project-name tripbot up -d --force-recreate --build tripbot_pgadmin", "pgadmin:logs": "docker container logs tripbot_pgadmin -f -n 100", diff --git a/src/discord/commands/guild/d.ai.ts b/src/discord/commands/guild/d.ai.ts index 19e104f97..a45f7953b 100644 --- a/src/discord/commands/guild/d.ai.ts +++ b/src/discord/commands/guild/d.ai.ts @@ -1,5 +1,4 @@ -// TODO: logit bias - +/* eslint-disable sonarjs/no-duplicate-string */ import { ActionRowBuilder, ModalBuilder, @@ -16,9 +15,16 @@ import { TextBasedChannel, CategoryChannel, ForumChannel, + ButtonBuilder, + ButtonStyle, + ButtonInteraction, + APIEmbedField, + EmbedBuilder, + APIButtonComponent, + APIActionRowComponent, + APIMessageComponentEmoji, } from 'discord.js'; import { - APIEmbedField, APIInteractionDataResolvedChannel, ChannelType, TextInputStyle, @@ -31,14 +37,15 @@ import { ai_personas, } from '@prisma/client'; import OpenAI from 'openai'; -import { Moderation } from 'openai/resources'; import { paginationEmbed } from '../../utils/pagination'; import { SlashCommand } from '../../@types/commandDef'; import { embedTemplate } from '../../utils/embedTemplate'; import commandContext from '../../utils/context'; -import { userInfoEmbed } from '../../../global/commands/g.moderate'; +import { moderate } from '../../../global/commands/g.moderate'; import { sleep } from './d.bottest'; import aiChat, { aiModerate } from '../../../global/commands/g.ai'; +import { UserActionType } from '../../../global/@types/database'; +import { parseDuration } from '../../../global/utils/parseDuration'; const db = new PrismaClient({ log: ['error'] }); @@ -57,6 +64,30 @@ const aiCosts = { input: 0.0015, output: 0.002, }, + GPT_3_5_TURBO_1106: { + input: 0.001, + output: 0.002, + }, + GPT_4: { + input: 0.03, + output: 0.06, + }, + GPT_4_1106_PREVIEW: { + input: 0.01, + output: 0.03, + }, + GPT_4_1106_VISION_PREVIEW: { + input: 0.01, + output: 0.03, + }, + DALL_E_2: { + input: 0.00, + output: 0.04, + }, + DALL_E_3: { + input: 0.00, + output: 0.02, + }, } as AiCosts; // define an object as series of keys (AiModel) and value that looks like {input: number, output: number} @@ -67,7 +98,7 @@ type AiCosts = { } }; -type AiAction = 'HELP' | 'NEW' | 'GET' | 'SET' | 'DEL' | 'LINK'; +type AiAction = 'HELP' | 'UPSERT' | 'GET' | 'DEL' | 'LINK' | 'MOD'; async function help( interaction: ChatInputCommandInteraction, @@ -217,7 +248,7 @@ async function makePersonaEmbed( ]); } -async function set( +async function upsert( interaction: ChatInputCommandInteraction, ):Promise { const personaName = interaction.options.getString('name') ?? interaction.user.username; @@ -262,11 +293,11 @@ async function set( // Get values let temperature = interaction.options.getNumber('temperature'); const topP = interaction.options.getNumber('top_p'); - log.debug(F, `temperature: ${temperature}, top_p: ${topP}`); + // log.debug(F, `temperature: ${temperature}, top_p: ${topP}`); // If both temperature and top_p are set, throw an error if (temperature && topP) { - log.debug(F, 'Both temperature and top_p are set'); + // log.debug(F, 'Both temperature and top_p are set'); embedTemplate() .setTitle('Modal') .setColor(Colors.Red) @@ -276,11 +307,15 @@ async function set( // If both temperature and top_p are NOT set, set temperature to 1 if (!temperature && !topP) { - log.debug(F, 'Neither temperature nor top_p are set'); + // log.debug(F, 'Neither temperature nor top_p are set'); temperature = 1; } - const userData = await db.users.findUniqueOrThrow({ where: { discord_id: interaction.user.id } }); + const userData = await db.users.upsert({ + where: { discord_id: interaction.user.id }, + create: { discord_id: interaction.user.id }, + update: { discord_id: interaction.user.id }, + }); const aiPersona = { name: personaName, @@ -304,7 +339,7 @@ async function set( await db.ai_personas.upsert({ where: { - name: aiPersona.id, + name: aiPersona.name, }, create: aiPersona, update: aiPersona, @@ -398,7 +433,7 @@ async function get( } } - log.debug(F, `aiPersona: ${JSON.stringify(aiPersona, null, 2)}`); + // log.debug(F, `aiPersona: ${JSON.stringify(aiPersona, null, 2)}`); if (!aiPersona) { await interaction.editReply({ @@ -777,16 +812,622 @@ async function link( }); } -async function isVerifiedMember(message:Message):Promise { - if (!message?.member) return false; - return message.member?.roles.cache.has(env.ROLE_VERIFIED); +async function mod( + interaction: ChatInputCommandInteraction, +):Promise { + if (!interaction.guild) return; + await interaction.deferReply({ ephemeral: true }); + + const moderationData = await db.ai_moderation.upsert({ + where: { + guild_id: interaction.guild.id, + }, + create: { + guild_id: interaction.guild.id, + }, + update: {}, + }); + + await db.ai_moderation.update({ + where: { + guild_id: interaction.guild.id, + }, + data: { + harassment: interaction.options.getNumber('harassment') ?? moderationData.harassment, + harassment_threatening: interaction.options.getNumber('harassment_threatening') ?? moderationData.harassment_threatening, + hate: interaction.options.getNumber('hate') ?? moderationData.hate, + hate_threatening: interaction.options.getNumber('hate_threatening') ?? moderationData.hate_threatening, + self_harm: interaction.options.getNumber('self_harm') ?? moderationData.self_harm, + self_harm_instructions: interaction.options.getNumber('self_harm_instructions') ?? moderationData.self_harm_instructions, + self_harm_intent: interaction.options.getNumber('self_harm_intent') ?? moderationData.self_harm_intent, + sexual: interaction.options.getNumber('sexual') ?? moderationData.sexual, + sexual_minors: interaction.options.getNumber('sexual_minors') ?? moderationData.sexual_minors, + violence: interaction.options.getNumber('violence') ?? moderationData.violence, + violence_graphic: interaction.options.getNumber('violence_graphic') ?? moderationData.violence_graphic, + }, + }); +} + +async function saveThreshold( + interaction: ButtonInteraction, +):Promise { + log.debug(F, 'saveThreshold started'); + const buttonID = interaction.customId; + log.debug(F, `buttonID: ${buttonID}`); + if (!interaction.guild) return; + + const [,, category, amount] = interaction.customId.split('~'); + const amountFloat = parseFloat(amount); + + const buttonRows = interaction.message.components.map(row => row.toJSON() as APIActionRowComponent); + // log.debug(F, `buttonRows: ${JSON.stringify(buttonRows, null, 2)}`); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const categoryRow = buttonRows.find(row => row.components.find(button => (button as any).custom_id?.includes(category))) as APIActionRowComponent; + // log.debug(F, `categoryRow: ${JSON.stringify(categoryRow, null, 2)}`); + + // Replace the save button with the new value + categoryRow.components?.splice(4, 1, { + custom_id: `aiMod~save~${category}~${amountFloat}`, + label: `Saved ${category} to ${amountFloat.toFixed(2)}`, + emoji: '💾' as APIMessageComponentEmoji, + style: ButtonStyle.Success, + type: 2, + } as APIButtonComponent); + + // Replace the category row with the new buttons + buttonRows.splice( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + buttonRows.findIndex(row => row.components.find(button => (button as any).custom_id?.includes(category))), + 1, + categoryRow, + ); + + const moderationData = await db.ai_moderation.upsert({ + where: { + guild_id: interaction.guild.id, + }, + create: { + guild_id: interaction.guild.id, + }, + update: {}, + }); + + const oldValue = moderationData[category as keyof typeof moderationData]; + + await db.ai_moderation.update({ + where: { + guild_id: interaction.guild.id, + }, + data: { + [category]: amountFloat, + }, + }); + + // Get the channel to send the message to + const channelAiModLog = await discordClient.channels.fetch(env.CHANNEL_AIMOD_LOG) as TextChannel; + await channelAiModLog.send({ + content: `${interaction.member} adjusted the ${category} limit from ${oldValue} to ${amountFloat}`, + }); + + await interaction.update({ + components: buttonRows, + }); +} + +async function adjustThreshold( + interaction: ButtonInteraction, +):Promise { + log.debug(F, 'adjustThreshold started'); + const buttonID = interaction.customId; + log.debug(F, `buttonID: ${buttonID}`); + + const [,, category, amount] = interaction.customId.split('~'); + const amountFloat = parseFloat(amount); + + // Go through the components on the message and find the button that has a customID that includes 'save' + const buttonRows = interaction.message.components.map(row => row.toJSON() as APIActionRowComponent); + // log.debug(F, `buttonRows: ${JSON.stringify(buttonRows, null, 2)}`); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const categoryRow = buttonRows.find(row => row.components.find(button => (button as any).custom_id?.includes(category))) as APIActionRowComponent; + // log.debug(F, `categoryRow: ${JSON.stringify(categoryRow, null, 2)}`); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const saveButton = categoryRow?.components.find(button => (button as any).custom_id?.includes('save')); + log.debug(F, `saveButton: ${JSON.stringify(saveButton, null, 2)}`); + + const saveValue = parseFloat(saveButton?.label?.split(' ')[3] as string); + // log.debug(F, `saveValue: ${JSON.stringify(saveValue, null, 2)}`); + + const newValue = saveValue + amountFloat; + log.debug(F, `newValue: ${JSON.stringify(newValue.toFixed(2), null, 2)}`); + + const newLabel = `Save ${category} at ${newValue.toFixed(2)}`; + log.debug(F, `newLabel: ${JSON.stringify(newLabel, null, 2)}`); + + // Replace the save button with the new value + categoryRow.components?.splice(4, 1, { + custom_id: `aiMod~save~${category}~${newValue}`, + label: newLabel, + emoji: '💾' as APIMessageComponentEmoji, + style: ButtonStyle.Primary, + type: 2, + } as APIButtonComponent); + + // Replace the category row with the new buttons + buttonRows.splice( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + buttonRows.findIndex(row => row.components.find(button => (button as any).custom_id?.includes(category))), + 1, + categoryRow, + ); + + // const newComponentList = newRows.map(row => ActionRowBuilder.from(row)); + + await interaction.update({ + components: buttonRows, + }); +} + +async function noteUser( + interaction: ButtonInteraction, +):Promise { + log.debug(F, 'noteUser started'); + const buttonID = interaction.customId; + log.debug(F, `buttonID: ${buttonID}`); + + const embed = interaction.message.embeds[0].toJSON(); + + const flagsField = embed.fields?.find(field => field.name === 'Flags') as APIEmbedField; + + await interaction.showModal(new ModalBuilder() + .setCustomId(`noteModal~${interaction.id}`) + .setTitle('Tripbot Note') + .addComponents(new ActionRowBuilder().addComponents(new TextInputBuilder() + .setLabel('What are you noting about this person?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Tell the team why you are noting this user.') + .setValue(`This user's message was flagged by the AI for ${flagsField.value}`) + .setMaxLength(1000) + .setRequired(true) + .setCustomId('internalNote')))); + const filter = (i:ModalSubmitInteraction) => i.customId.includes('noteModal'); + + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[1] !== interaction.id) return; + await i.deferReply({ ephemeral: true }); + + const messageField = embed.fields?.find(field => field.name === 'Message') as APIEmbedField; + const memberField = embed.fields?.find(field => field.name === 'Member') as APIEmbedField; + const urlField = embed.fields?.find(field => field.name === 'Channel') as APIEmbedField; + + await moderate( + interaction.member as GuildMember, + 'NOTE' as UserActionType, + memberField.value.slice(2, -1), + stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${messageField.value} + ${urlField.value} + `, + null, + null, + ); + + const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); + + const actionField = embed.fields?.find(field => field.name === 'Actions'); + + if (actionField) { + // Add the action to the list of actions + const newActionFiled = actionField?.value.concat(` + + ${interaction.user.toString()} noted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > **No message sent to user on notes** + `); + // log.debug(F, `newActionFiled: ${newActionFiled}`); + + // Replace the action field with the new one + embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { + name: 'Actions', + value: newActionFiled, + inline: true, + }); + } else { + embed.fields?.push( + { + name: 'Actions', + value: stripIndents`${interaction.user.toString()} noted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`, + inline: true, + }, + ); + } + embed.color = Colors.Green; + + await i.editReply('User was noted'); + + await interaction.message.edit({ + embeds: [embed], + components: buttonRows as ActionRowBuilder[], + }); + }); +} + +async function muteUser( + interaction: ButtonInteraction, +):Promise { + log.debug(F, 'muteUser started'); + const buttonID = interaction.customId; + log.debug(F, `buttonID: ${buttonID}`); + + const embed = interaction.message.embeds[0].toJSON(); + + const flagsField = embed.fields?.find(field => field.name === 'Flags') as APIEmbedField; + const messageField = embed.fields?.find(field => field.name === 'Message') as APIEmbedField; + const urlField = embed.fields?.find(field => field.name === 'Channel') as APIEmbedField; + + await interaction.showModal(new ModalBuilder() + .setCustomId(`timeoutModal~${interaction.id}`) + .setTitle('Tripbot Timeout') + .addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder() + .setLabel('Why are you muting this person?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Tell the team why you are muting this user.') + .setValue(`This user breaks TripSit's policies regarding ${flagsField.value} topics.`) + .setMaxLength(1000) + .setRequired(true) + .setCustomId('internalNote')), + new ActionRowBuilder().addComponents(new TextInputBuilder() + .setLabel('What should we tell the user?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('This will be sent to the user!') + .setValue(stripIndents` + Your recent messages have broken TripSit's policies regarding ${flagsField.value} topics. + + The offending message + > ${messageField.value} + ${urlField.value}`) + .setMaxLength(1000) + .setRequired(false) + .setCustomId('description')), + new ActionRowBuilder().addComponents(new TextInputBuilder() + .setLabel('Timeout for how long? (Max/default 7 days)') + .setStyle(TextInputStyle.Short) + .setPlaceholder('4 days 3hrs 2 mins 30 seconds') + .setRequired(false) + .setCustomId('timeoutDuration')), + )); + const filter = (i:ModalSubmitInteraction) => i.customId.includes('timeoutModal'); + + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[1] !== interaction.id) return; + await i.deferReply({ ephemeral: true }); + + const duration = i.fields.getTextInputValue('timeoutDuration') + ? await parseDuration(i.fields.getTextInputValue('timeoutDuration')) + : 604800000; + + if (duration > 604800000) { + await i.editReply('Cannot remove messages older than 7 days.'); + return; + } + + const memberField = embed.fields?.find(field => field.name === 'Member') as APIEmbedField; + + await moderate( + interaction.member as GuildMember, + 'TIMEOUT' as UserActionType, + memberField.value.slice(2, -1), + stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${messageField.value} + ${urlField.value} + `, + i.fields.getTextInputValue('description'), + duration, + ); + + const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); + + const actionField = embed.fields?.find(field => field.name === 'Actions'); + + if (actionField) { + // Add the action to the list of actions + const newActionFiled = actionField?.value.concat(` + + ${interaction.user.toString()} muted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`); + // log.debug(F, `newActionFiled: ${newActionFiled}`); + + // Replace the action field with the new one + embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { + name: 'Actions', + value: newActionFiled, + inline: true, + }); + } else { + embed.fields?.push( + { + name: 'Actions', + value: stripIndents`${interaction.user.toString()} muted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`, + inline: true, + }, + ); + } + embed.color = Colors.Green; + + await i.editReply('User was muted'); + + await interaction.message.edit({ + embeds: [embed], + components: buttonRows as ActionRowBuilder[], + }); + }); +} + +async function warnUser( + interaction: ButtonInteraction, +):Promise { + log.debug(F, 'warnUser started'); + const buttonID = interaction.customId; + log.debug(F, `buttonID: ${buttonID}`); + + const embed = interaction.message.embeds[0].toJSON(); + + const flagsField = embed.fields?.find(field => field.name === 'Flags') as APIEmbedField; + const urlField = embed.fields?.find(field => field.name === 'Channel') as APIEmbedField; + const messageField = embed.fields?.find(field => field.name === 'Message') as APIEmbedField; + + await interaction.showModal(new ModalBuilder() + .setCustomId(`warnModal~${interaction.id}`) + .setTitle('Tripbot Warn') + .addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder() + .setLabel('Why are you warning this person?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Tell the team why you are warning this user.') + .setValue(`This user breaks TripSit's policies regarding ${flagsField.value} topics.`) + .setMaxLength(1000) + .setRequired(true) + .setCustomId('internalNote')), + new ActionRowBuilder().addComponents(new TextInputBuilder() + .setLabel('What should we tell the user?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('This will be sent to the user!') + .setValue(stripIndents`Your recent messages have broken TripSit's policies regarding ${flagsField.value} topics. + + The offending message + > ${messageField.value} + ${urlField.value}`) + .setMaxLength(1000) + .setRequired(true) + .setCustomId('description')), + )); + const filter = (i:ModalSubmitInteraction) => i.customId.includes('warnModal'); + + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[1] !== interaction.id) return; + await i.deferReply({ ephemeral: true }); + + const memberField = embed.fields?.find(field => field.name === 'Member') as APIEmbedField; + + await moderate( + interaction.member as GuildMember, + 'WARNING' as UserActionType, + memberField.value.slice(2, -1), + stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${messageField.value} + ${urlField.value} + `, + i.fields.getTextInputValue('description'), + null, + ); + + const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); + + const actionField = embed.fields?.find(field => field.name === 'Actions'); + + if (actionField) { + // Add the action to the list of actions + const newActionFiled = actionField?.value.concat(` + + ${interaction.user.toString()} warned this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`); + // log.debug(F, `newActionFiled: ${newActionFiled}`); + + // Replace the action field with the new one + embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { + name: 'Actions', + value: newActionFiled, + inline: true, + }); + } else { + embed.fields?.push( + { + name: 'Actions', + value: stripIndents`${interaction.user.toString()} warned this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`, + inline: true, + }, + ); + } + embed.color = Colors.Green; + + await i.editReply('User was warned'); + + await interaction.message.edit({ + embeds: [embed], + components: buttonRows as ActionRowBuilder[], + }); + }); +} + +async function banUser( + interaction: ButtonInteraction, +):Promise { + log.debug(F, 'banUser started'); + const buttonID = interaction.customId; + log.debug(F, `buttonID: ${buttonID}`); + + const embed = interaction.message.embeds[0].toJSON(); + + const flagsField = embed.fields?.find(field => field.name === 'Flags') as APIEmbedField; + const urlField = embed.fields?.find(field => field.name === 'Channel') as APIEmbedField; + const messageField = embed.fields?.find(field => field.name === 'Message') as APIEmbedField; + + await interaction.showModal(new ModalBuilder() + .setCustomId(`banModal~${interaction.id}`) + .setTitle('Tripbot Ban') + .addComponents( + new ActionRowBuilder().addComponents(new TextInputBuilder() + .setLabel('Why are you banning this user?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('Tell the team why you are banning this user.') + .setValue(`This user breaks TripSit's policies regarding ${flagsField.value} topics.`) + .setMaxLength(1000) + .setRequired(true) + .setCustomId('internalNote')), + new ActionRowBuilder().addComponents(new TextInputBuilder() + .setLabel('What should we tell the user?') + .setStyle(TextInputStyle.Paragraph) + .setPlaceholder('This will be sent to the user!') + .setValue(stripIndents`Your recent messages have broken TripSit's policies regarding ${flagsField.value} topics. + + The offending message + > ${messageField.value} + ${urlField.value}`) + .setMaxLength(1000) + .setRequired(false) + .setCustomId('description')), + new ActionRowBuilder().addComponents(new TextInputBuilder() + .setLabel('How many days of msg to remove?') + .setStyle(TextInputStyle.Short) + .setPlaceholder('Between 0 and 7 days (Default 0)') + .setRequired(false) + .setCustomId('duration')), + )); + const filter = (i:ModalSubmitInteraction) => i.customId.includes('banModal'); + + interaction.awaitModalSubmit({ filter, time: 0 }) + .then(async i => { + if (i.customId.split('~')[1] !== interaction.id) return; + await i.deferReply({ ephemeral: true }); + + const duration = i.fields.getTextInputValue('duration') + ? await parseDuration(i.fields.getTextInputValue('duration')) + : 0; + + if (duration > 604800000) { + await i.editReply('Cannot remove messages older than 7 days.'); + return; + } + + const memberField = embed.fields?.find(field => field.name === 'Member') as APIEmbedField; + + await moderate( + interaction.member as GuildMember, + 'FULL_BAN' as UserActionType, + memberField.value.slice(2, -1), + stripIndents` + ${i.fields.getTextInputValue('internalNote')} + + **The offending message** + > ${messageField.value} + ${urlField.value} + `, + i.fields.getTextInputValue('description'), + duration, + ); + + const buttonRows = interaction.message.components.map(row => ActionRowBuilder.from(row.toJSON())); + + const actionField = embed.fields?.find(field => field.name === 'Actions'); + + if (actionField) { + // Add the action to the list of actions + const newActionFiled = actionField?.value.concat(` + + ${interaction.user.toString()} banned this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`); + // log.debug(F, `newActionFiled: ${newActionFiled}`); + + // Replace the action field with the new one + embed.fields?.splice(embed.fields?.findIndex(field => field.name === 'Actions'), 1, { + name: 'Actions', + value: newActionFiled, + inline: true, + }); + } else { + embed.fields?.push( + { + name: 'Actions', + value: stripIndents`${interaction.user.toString()} noted this user: + > ${i.fields.getTextInputValue('internalNote')} + + Message sent to user: + > ${i.fields.getTextInputValue('description')}`, + inline: true, + }, + ); + } + embed.color = Colors.Green; + + await i.editReply('User was banned'); + + await interaction.message.edit({ + embeds: [embed], + components: buttonRows as ActionRowBuilder[], + }); + }); } +// export async function aiModResults( + +// ) { +// const moderation = await aiModResults(message); + +// if (moderation.length === 0) return; +// }; + export async function aiAudit( aiPersona: ai_personas | null, messages: Message[], - chatResponse: string | null, - modResponse: Moderation | null, + chatResponse: string, promptTokens: number, completionTokens: number, ) { @@ -797,162 +1438,230 @@ export async function aiAudit( const channelAiLog = await discordClient.channels.fetch(env.CHANNEL_AILOG) as TextChannel; // Check if this is a chat completion response, else, it's a moderation response - if (chatResponse) { - if (!aiPersona) return; - // Get fresh persona data after tokens + if (!aiPersona) return; + // Get fresh persona data after tokens - const cleanPersona = await db.ai_personas.findUniqueOrThrow({ - where: { - id: aiPersona.id, - }, - }); + const cleanPersona = await db.ai_personas.findUniqueOrThrow({ + where: { + id: aiPersona.id, + }, + }); - // const embed = await makePersonaEmbed(cleanPersona); - const embed = embedTemplate(); + // const embed = await makePersonaEmbed(cleanPersona); + const embed = embedTemplate(); - // Construct the message - embed.setFooter({ text: 'What are tokens? https://platform.openai.com/tokenizer' }); + // Construct the message + embed.setFooter({ text: 'What are tokens? https://platform.openai.com/tokenizer' }); - const messageOutput = messages - .map(message => `${message.url} ${message.member?.displayName}: ${message.cleanContent}`) - .join('\n') - .slice(0, 1024); + const messageOutput = messages + .map(message => `${message.url} ${message.member?.displayName}: ${message.cleanContent}`) + .join('\n') + .slice(0, 1024); - // log.debug(F, `messageOutput: ${messageOutput}`); + // log.debug(F, `messageOutput: ${messageOutput}`); - const responseOutput = chatResponse.slice(0, 1023); - // log.debug(F, `responseOutput: ${responseOutput}`); + const responseOutput = chatResponse.slice(0, 1023); + // log.debug(F, `responseOutput: ${responseOutput}`); - embed.spliceFields( - 0, - 0, + embed.spliceFields( + 0, + 0, + { + name: 'Model', + value: stripIndents`${aiPersona.ai_model}`, + inline: false, + }, + { + name: 'Messages', + value: stripIndents`${messageOutput}`, + inline: false, + }, + { + name: 'Result', + value: stripIndents`${responseOutput}`, + inline: false, + }, + ); + + const promptCost = (promptTokens / 1000) * aiCosts[cleanPersona.ai_model].input; + const completionCost = (completionTokens / 1000) * aiCosts[cleanPersona.ai_model].output; + // log.debug(F, `promptCost: ${promptCost}, completionCost: ${completionCost}`); + + embed.spliceFields( + 2, + 0, + { + name: 'Prompt Cost', + value: `$${promptCost.toFixed(6)}\n(${promptTokens} tokens)`, + inline: true, + }, + { + name: 'Completion Cost', + value: `$${completionCost.toFixed(6)}\n(${completionTokens} tokens)`, + inline: true, + }, + { + name: 'Total Cost', + value: `$${(promptCost + completionCost).toFixed(6)}\n(${promptTokens + completionTokens} tokens)`, + inline: true, + }, + ); + + // Send the message + await channelAiLog.send({ embeds: [embed] }); +} + +export async function discordAiModerate( + messageData:Message, +):Promise { + if (messageData.author.bot) return; + if (messageData.cleanContent.length < 1) return; + if (messageData.channel.type === ChannelType.DM) return; + if (!messageData.guild) return; + + const modResults = await aiModerate( + messageData.cleanContent.replace(tripbotUAT, '').replace('tripbot', ''), + messageData.guild.id, + ); + + if (modResults.length === 0) return; + + const activeFlags = modResults.map(modResult => modResult.category); + + const targetMember = messageData.member as GuildMember; + // const userData = await db.users.upsert({ + // where: { discord_id: guildMember.id }, + // create: { discord_id: guildMember.id }, + // update: {}, + // }); + + // const aiEmbed = await userInfoEmbed(guildMember, userData, 'FLAGGED'); + const aiEmbed = new EmbedBuilder() + .setThumbnail(targetMember.user.displayAvatarURL()) + .setColor(Colors.Yellow) + .addFields( { - name: 'Messages', - value: stripIndents`${messageOutput}`, - inline: false, + name: 'Member', + value: stripIndents`<@${targetMember.id}>`, + inline: true, + }, + { + name: 'Flags', + value: stripIndents`${activeFlags.join(', ')}`, + inline: true, + }, + { + name: 'Channel', + value: stripIndents`${messageData.url}`, + inline: true, }, { - name: 'Result', - value: stripIndents`${responseOutput}`, + name: 'Message', + value: stripIndents`${messageData.cleanContent}`, inline: false, }, ); - const promptCost = (promptTokens / 1000) * aiCosts[cleanPersona.ai_model].input; - const completionCost = (completionTokens / 1000) * aiCosts[cleanPersona.ai_model].output; - // log.debug(F, `promptCost: ${promptCost}, completionCost: ${completionCost}`); - - embed.spliceFields( - 2, - 0, + const modAiModifyButtons = [] as ActionRowBuilder[]; + // For each of the sortedCategoryScores, add a field + modResults.forEach(result => { + aiEmbed.addFields( { - name: 'Prompt Cost', - value: `$${promptCost.toFixed(6)}\n(${promptTokens} tokens)`, + name: result.category, + value: '\u200B', inline: true, }, { - name: 'Completion Cost', - value: `$${completionCost.toFixed(6)}\n(${completionTokens} tokens)`, + name: 'AI Value', + value: `${result.value.toFixed(2)}`, inline: true, }, { - name: 'Total Cost', - value: `$${(promptCost + completionCost).toFixed(6)}\n(${promptTokens + completionTokens} tokens)`, + name: 'Threshold Value', + value: `${result.limit}`, inline: true, }, ); + modAiModifyButtons.push(new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`aiMod~adjust~${result.category}~-0.10`) + .setLabel('-0.10') + .setEmoji('⏪') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`aiMod~adjust~${result.category}~-0.01`) + .setLabel('-0.01') + .setEmoji('◀️') + .setStyle(ButtonStyle.Danger), + new ButtonBuilder() + .setCustomId(`aiMod~adjust~${result.category}~+0.01`) + .setLabel('+0.01') + .setEmoji('▶️') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`aiMod~adjust~${result.category}~+0.10`) + .setLabel('+0.10') + .setEmoji('⏩') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`aiMod~save~${result.category}~${result.limit}`) + .setLabel(`Save ${result.category} at ${result.limit.toFixed(2)}`) + .setEmoji('💾') + .setStyle(ButtonStyle.Primary), + )); + }); - // Send the message - await channelAiLog.send({ embeds: [embed] }); - } else if (modResponse) { - if (!modResponse.flagged) return; - // Check which of the modData.categories are true - const activeFlags = [] as string[]; - Object.entries(modResponse.categories).forEach(([key, val]) => { - if (val) { - activeFlags.push(key); - } - }); - - const message = messages[0]; - const guildMember = message.member as GuildMember; - - const targetData = await db.users.findUniqueOrThrow({ - where: { - discord_id: guildMember.id, - }, - }); - - const modlogEmbed = await userInfoEmbed(guildMember, targetData, 'FLAGGED'); - - const field = { - name: `Flagged by AI for **${activeFlags.join(', ')}** in ${message.url}`, - value: `> ${message.content}`, - inline: false, - } as APIEmbedField; - - modlogEmbed.spliceFields(0, 0, field); - - // Sort modData.category_scores by score - const sortedScores = Object.entries(modResponse.category_scores) - .sort(([,a], [,b]) => b - a) - .reduce((acc, [key, val]) => ({ ...acc, [key]: val }), {}); - - // For each of the sortedCategoryScores, add a field - Object.entries(sortedScores).forEach(([key, val]) => { - log.debug(F, `key: ${key} val: ${val}`); - // Get if this category was flagged or not - const flagged = modResponse.categories[key as keyof typeof modResponse.categories]; - log.debug(F, `flagged: ${flagged}`); - // Add a field to the embed - modlogEmbed.addFields({ - name: key, - value: `${val}`, - inline: true, - }); - }); - - log.debug(F, `User: ${messages[0].member?.displayName} Flags: ${activeFlags.join(', ')}`); + const userActions = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId(`aiMod~note~${messageData.author.id}`) + .setLabel('Note') + .setEmoji('🗒️') + .setStyle(ButtonStyle.Success), + new ButtonBuilder() + .setCustomId(`aiMod~warn~${messageData.author.id}`) + .setLabel('Warn') + .setEmoji('⚠️') + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId(`aiMod~timeout~${messageData.author.id}`) + .setLabel('Mute') + .setEmoji('⏳') + .setStyle(ButtonStyle.Secondary), + new ButtonBuilder() + .setCustomId(`aiMod~ban~${messageData.author.id}`) + .setLabel('Ban') + .setEmoji('🔨') + .setStyle(ButtonStyle.Danger), + ); - // Send the message - await channelAiLog.send({ - content: `Hey <@${env.DISCORD_OWNER_ID}> a message was flagged for **${activeFlags.join(', ')}**`, - embeds: [modlogEmbed], - }); - } -} + // Get the channel to send the message to + const channelAiModLog = await discordClient.channels.fetch(env.CHANNEL_AIMOD_LOG) as TextChannel; -/** - * Sends a message to the moderation AI and returns the response - * @param {Message} message The interaction that spawned this commend - * @return {Promise} The response from the AI - */ -export async function discordAiModerate( - message:Message, -):Promise { - const [result] = await aiModerate(message.cleanContent - .replace(tripbotUAT, '') - .replace('tripbot', '')); - await aiAudit(null, [message], null, result, 0, 0); + // Send the message + await channelAiModLog.send({ + content: `${targetMember} was flagged by AI for ${activeFlags.join(', ')} in ${messageData.url} <@${env.DISCORD_OWNER_ID}>`, + embeds: [aiEmbed], + components: [userActions, ...modAiModifyButtons], + }); } export async function discordAiChat( messageData: Message, ):Promise { - log.debug(F, 'Started!'); - log.debug(F, `messageData: ${JSON.stringify(messageData.cleanContent, null, 2)}`); - const channelMessages = await messageData.channel.messages.fetch({ limit: 3 }); - log.debug(F, `channelMessages: ${JSON.stringify(channelMessages, null, 2)}`); + // log.debug(F, `discordAiChat - messageData: ${JSON.stringify(messageData.cleanContent, null, 2)}`); + const channelMessages = await messageData.channel.messages.fetch({ limit: 10 }); + // log.debug(F, `channelMessages: ${JSON.stringify(channelMessages.map(message => message.cleanContent), null, 2)}`); const messages = [...channelMessages.values()]; - if (!isVerifiedMember(messages[0])) return; + if (!messages[0].member?.roles.cache.has(env.ROLE_VERIFIED)) return; if (messages[0].author.bot) return; if (messages[0].cleanContent.length < 1) return; if (messages[0].channel.type === ChannelType.DM) return; // Check if the channel is linked to a persona const aiLinkData = await getLinkedChannel(messages[0].channel); + // log.debug(F, `aiLinkData: ${JSON.stringify(aiLinkData, null, 2)}`); if (!aiLinkData) return; // log.debug(F, `aiLinkData: ${JSON.stringify(aiLinkData, null, 2)}`); @@ -988,7 +1697,6 @@ export async function discordAiChat( aiPersona, cleanMessageList, result.response, - null, result.promptTokens, result.completionTokens, ); @@ -1004,6 +1712,37 @@ export async function discordAiChat( await messages[0].reply(result.response.slice(0, 2000)); } +export async function aiModButton( + interaction: ButtonInteraction, +) { + const buttonID = interaction.customId; + log.debug(F, `buttonID: ${buttonID}`); + const [,buttonAction] = buttonID.split('~'); + + switch (buttonAction) { + case 'adjust': + await adjustThreshold(interaction); + break; + case 'save': + await saveThreshold(interaction); + break; + case 'note': + await noteUser(interaction); + break; + case 'warn': + await warnUser(interaction); + break; + case 'timeout': + await muteUser(interaction); + break; + case 'ban': + await banUser(interaction); + break; + default: + break; + } +} + export const aiCommand: SlashCommand = { data: new SlashCommandBuilder() .setName('ai') @@ -1012,42 +1751,10 @@ export const aiCommand: SlashCommand = { .setDescription('Information on the AI persona.') .setName('help')) .addSubcommand(subcommand => subcommand - .setDescription('Create a new AI persona.') - .addStringOption(option => option.setName('name') - .setDescription('Name of the AI persona.')) - .addStringOption(option => option.setName('model') - .setDescription('Which model to use.') - .setAutocomplete(true)) - .addStringOption(option => option.setName('channels') - .setDescription('CSV of channel/category IDs.')) - .addNumberOption(option => option.setName('tokens') - .setDescription('Maximum tokens to use for this request (Default: 500).') - .setMaxValue(1000) - .setMinValue(100)) - .addNumberOption(option => option.setName('temperature') - .setDescription('Temperature value for the model.') - .setMaxValue(2) - .setMinValue(0)) - .addNumberOption(option => option.setName('top_p') - .setDescription('Top % value for the model.') - .setMaxValue(2) - .setMinValue(0)) - .addNumberOption(option => option.setName('presence_penalty') - .setDescription('Presence penalty value for the model.') - .setMaxValue(2) - .setMinValue(-2)) - .addNumberOption(option => option.setName('frequency_penalty') - .setDescription('Frequency penalty value for the model.') - .setMaxValue(2) - .setMinValue(-2)) - .addBooleanOption(option => option.setName('ephemeral') - .setDescription(ephemeralExplanation)) - .setName('new')) - .addSubcommand(subcommand => subcommand - .setDescription('Set a setting on an AI persona') + .setDescription('Set a create or update an AI persona') .addStringOption(option => option.setName('name') .setAutocomplete(true) - .setDescription('Name of the AI persona to modify.')) + .setDescription('Name of the AI persona to modify/create.')) .addStringOption(option => option.setName('model') .setAutocomplete(true) .setDescription('Which model to use.')) @@ -1060,7 +1767,7 @@ export const aiCommand: SlashCommand = { .setMaxValue(2) .setMinValue(0)) .addNumberOption(option => option.setName('top_p') - .setDescription('Top % value for the model.') + .setDescription('Top % value for the model. Use this OR temp.') .setMaxValue(2) .setMinValue(0)) .addNumberOption(option => option.setName('presence_penalty') @@ -1073,7 +1780,7 @@ export const aiCommand: SlashCommand = { .setMinValue(-2)) .addBooleanOption(option => option.setName('ephemeral') .setDescription(ephemeralExplanation)) - .setName('set')) + .setName('upsert')) .addSubcommand(subcommand => subcommand .setDescription('Get information on the AI') .addStringOption(option => option.setName('name') @@ -1109,7 +1816,32 @@ export const aiCommand: SlashCommand = { )) .addBooleanOption(option => option.setName('ephemeral') .setDescription(ephemeralExplanation)) - .setName('link')), + .setName('link')) + .addSubcommand(subcommand => subcommand + .setDescription('Change moderation parameters.') + .addNumberOption(option => option.setName('harassment') + .setDescription('Set harassment limit.')) + .addNumberOption(option => option.setName('harassment_threatening') + .setDescription('Set harassment_threatening limit.')) + .addNumberOption(option => option.setName('hate') + .setDescription('Set hate limit.')) + .addNumberOption(option => option.setName('hate_threatening') + .setDescription('Set hate_threatening limit.')) + .addNumberOption(option => option.setName('self_harm') + .setDescription('Set self_harm limit.')) + .addNumberOption(option => option.setName('self_harm_instructions') + .setDescription('Set self_harm_instructions limit.')) + .addNumberOption(option => option.setName('self_harm_intent') + .setDescription('Set self_harm_intent limit.')) + .addNumberOption(option => option.setName('sexual') + .setDescription('Set sexual limit.')) + .addNumberOption(option => option.setName('sexual_minors') + .setDescription('Set sexual_minors limit.')) + .addNumberOption(option => option.setName('violence') + .setDescription('Set violence limit.')) + .addNumberOption(option => option.setName('violence_graphic') + .setDescription('Set violence_graphic limit.')) + .setName('mod')), async execute(interaction) { log.info(F, await commandContext(interaction)); @@ -1119,14 +1851,11 @@ export const aiCommand: SlashCommand = { case 'HELP': await help(interaction); break; - case 'NEW': - await set(interaction); - break; case 'GET': await get(interaction); break; - case 'SET': - await set(interaction); + case 'UPSERT': + await upsert(interaction); break; case 'LINK': await link(interaction); @@ -1134,6 +1863,9 @@ export const aiCommand: SlashCommand = { case 'DEL': await del(interaction); break; + case 'MOD': + await mod(interaction); + break; default: help(interaction); break; diff --git a/src/discord/events/autocomplete.ts b/src/discord/events/autocomplete.ts index 9d694c6e2..da7884267 100644 --- a/src/discord/events/autocomplete.ts +++ b/src/discord/events/autocomplete.ts @@ -4,7 +4,7 @@ import { } from 'discord.js'; import Fuse from 'fuse.js'; -import { PrismaClient } from '@prisma/client'; +import { PrismaClient, ai_model } from '@prisma/client'; import pillColors from '../../global/assets/data/pill_colors.json'; import pillShapes from '../../global/assets/data/pill_shapes.json'; import drugDataAll from '../../global/assets/data/drug_db_combined.json'; @@ -480,14 +480,7 @@ async function autocompleteAiModels(interaction:AutocompleteInteraction) { 'name', ], }; - const modelList = [ - { name: 'GPT_3_5_TURBO' }, - { name: 'GPT_4' }, - { name: 'DAVINCI' }, - { name: 'CURIE' }, - { name: 'BABBAGE' }, - { name: 'ADA' }, - ]; + const modelList = Object.keys(ai_model).map(model => ({ name: model })); const fuse = new Fuse(modelList, options); const focusedValue = interaction.options.getFocused(); diff --git a/src/discord/events/buttonClick.ts b/src/discord/events/buttonClick.ts index 6f929ca30..1dca82ad4 100644 --- a/src/discord/events/buttonClick.ts +++ b/src/discord/events/buttonClick.ts @@ -25,6 +25,7 @@ import { import { helperButton } from '../commands/global/d.setup'; import { appealAccept, appealReject } from '../utils/appeal'; import { mushroomPageOne, mushroomPageTwo } from '../commands/global/d.mushroom_info'; +import { aiModButton } from '../commands/guild/d.ai'; const F = f(__filename); @@ -32,7 +33,7 @@ export default buttonClick; export async function buttonClick(interaction:ButtonInteraction, discordClient:Client) { log.info(F, await commandContext(interaction)); - log.debug(F, 'Interaction deferred!'); + // log.debug(F, 'Interaction deferred!'); const buttonID = interaction.customId; if (buttonID.startsWith('mushroom')) { @@ -49,6 +50,12 @@ export async function buttonClick(interaction:ButtonInteraction, discordClient:C } } + if (buttonID.startsWith('aiMod')) { + // log.debug(F, 'aiMod button clicked'); + await aiModButton(interaction); + return; + } + if (buttonID.startsWith('rpg')) { if (!buttonID.includes(interaction.user.id)) { log.debug(F, 'Button clicked by someone other than the user who clicked it'); diff --git a/src/global/commands/g.ai.ts b/src/global/commands/g.ai.ts index 45f65fc41..677c8f414 100644 --- a/src/global/commands/g.ai.ts +++ b/src/global/commands/g.ai.ts @@ -1,20 +1,26 @@ +// alerts when something is higher than 90 + import OpenAI from 'openai'; import { PrismaClient, ai_personas } from '@prisma/client'; -import { Moderation } from 'openai/resources'; +import { ModerationCreateResponse } from 'openai/resources'; const db = new PrismaClient({ log: ['error'] }); const F = f(__filename); const openai = new OpenAI({ - organization: 'org-h4Jvunqw3MmHmIgeLHpr1a3Y', + organization: env.OPENAI_API_ORG, apiKey: env.OPENAI_API_KEY, }); +type ModerationResult = { + category: string, + value: number, + limit: number, +}; + // Objective truths are facts and don't impact personality -const objectiveTruths = { - role: 'system', - content: ` +const objectiveTruths = ` Your name is TripBot, you are on TripSit Discord. You were born on Sept 26, 2011 on IRC and moved to discord in 2022. Your father is Moonbear and your mother is Reality. @@ -28,11 +34,11 @@ const objectiveTruths = { The moderators are: Foggy, Aida, Elixir, Spacelady, Hipperooni, WorriedHobbiton, Zombie and Trees. Keep all responses under 2000 characters at maximum. -`, -} as OpenAI.Chat.ChatCompletionSystemMessageParam; +`; // # Example dummy function hard coded to return the same weather // # In production, this could be your backend API or an external API +// eslint-disable-next-line @typescript-eslint/no-unused-vars // async function getCurrentWeather(location:string, unit = 'fahrenheit') { // return { // location, @@ -42,6 +48,28 @@ const objectiveTruths = { // }; // } +export async function aiModerateReport( + message: string, +):Promise { + // log.debug(F, `message: ${message}`); + + // log.debug(F, `results: ${JSON.stringify(results, null, 2)}`); + return openai.moderations + .create({ + input: message, + }) + .catch(err => { + if (err instanceof OpenAI.APIError) { + log.error(F, `${err.status}`); // 400 + log.error(F, `${err.name}`); // BadRequestError + + log.error(F, `${err.headers}`); // {server: 'nginx', ...} + } else { + throw err; + } + }); +} + // const aiFunctions = [ // { // name: 'getCurrentWeather', @@ -58,6 +86,20 @@ const objectiveTruths = { // required: ['location'], // }, // }, +// { +// name: 'aiModerateReport', +// description: 'Get a report on how the AI rates a message', +// parameters: { +// type: 'object', +// properties: { +// message: { +// type: 'string', +// description: 'The message you want the AI to analyze', +// }, +// }, +// required: ['message'], +// }, +// }, // ]; /** @@ -78,10 +120,14 @@ export default async function aiChat( let promptTokens = 0; let completionTokens = 0; + log.debug(F, `aiPersona: ${JSON.stringify(aiPersona.name, null, 2)}`); + let model = aiPersona.ai_model as string; // Convert ai models into proper names if (aiPersona.ai_model === 'GPT_3_5_TURBO') { model = 'gpt-3.5-turbo'; + } else if (aiPersona.ai_model === 'GPT_4') { + model = 'gpt-4-1106-preview'; } else { model = aiPersona.ai_model.toLowerCase(); } @@ -89,13 +135,25 @@ export default async function aiChat( // This message list is sent to the API const chatCompletionMessages = [{ role: 'system', - content: aiPersona.prompt, + content: aiPersona.prompt.concat(objectiveTruths), }] as OpenAI.Chat.ChatCompletionMessageParam[]; - chatCompletionMessages.unshift(objectiveTruths); chatCompletionMessages.push(...messages); + // eslint-disable-next-line @typescript-eslint/naming-convention + const { + id, + name, + created_at, // eslint-disable-line @typescript-eslint/naming-convention + created_by, // eslint-disable-line @typescript-eslint/naming-convention + prompt, + logit_bias, // eslint-disable-line @typescript-eslint/naming-convention + total_tokens, // eslint-disable-line @typescript-eslint/naming-convention + ai_model, // eslint-disable-line @typescript-eslint/naming-convention + ...restOfAiPersona + } = aiPersona; + const payload = { - ...aiPersona, + ...restOfAiPersona, model, messages: chatCompletionMessages, // functions: aiFunctions, @@ -108,15 +166,15 @@ export default async function aiChat( .create(payload) .catch(err => { if (err instanceof OpenAI.APIError) { - log.error(F, `${err.status}`); // 400 - log.error(F, `${err.name}`); // BadRequestError - - log.error(F, `${err.headers}`); // {server: 'nginx', ...} + // eslint-disable-next-line @typescript-eslint/no-explicit-any + log.error(F, `${err.name} - ${err.status} - ${err.type} - ${(err.error as any).message} `); // 400 + // log.error(F, `${JSON.stringify(err.headers, null, 2)}`); // {server: 'nginx', ...} + // log.error(F, `${JSON.stringify(err, null, 2)}`); // {server: 'nginx', ...} } else { throw err; } }); - // log.debug(F, `chatCompletion: ${JSON.stringify(chatCompletion, null, 2)}`); + log.debug(F, `chatCompletion: ${JSON.stringify(chatCompletion, null, 2)}`); if (chatCompletion?.choices[0].message) { responseMessage = chatCompletion.choices[0].message; @@ -125,24 +183,25 @@ export default async function aiChat( promptTokens = chatCompletion.usage?.prompt_tokens ?? 0; completionTokens = chatCompletion.usage?.completion_tokens ?? 0; - // // # Step 2: check if GPT wanted to call a function + // # Step 2: check if GPT wanted to call a function // if (responseMessage.function_call) { - // // log.debug(F, `responseMessage.function_call: ${JSON.stringify(responseMessage.function_call, null, 2)}`); + // log.debug(F, `responseMessage.function_call: ${JSON.stringify(responseMessage.function_call, null, 2)}`); // // # Step 3: call the function // // # Note: the JSON response may not always be valid; be sure to handle errors // const availableFunctions = { // getCurrentWeather, + // aiModerateReport, // }; // const functionName = responseMessage.function_call.name; // log.debug(F, `functionName: ${functionName}`); - // const fuctionToCall = availableFunctions[functionName as keyof typeof availableFunctions]; + // const functionToCall = availableFunctions[functionName as keyof typeof availableFunctions]; // const functionArgs = JSON.parse(responseMessage.function_call.arguments as string); - // const functionResponse = await fuctionToCall( + // const functionResponse = await functionToCall( // functionArgs.location, // functionArgs.unit, // ); - // // log.debug(F, `functionResponse: ${JSON.stringify(functionResponse, null, 2)}`); + // log.debug(F, `functionResponse: ${JSON.stringify(functionResponse, null, 2)}`); // // # Step 4: send the info on the function call and function response to GPT // payload.messages.push({ @@ -151,18 +210,18 @@ export default async function aiChat( // content: JSON.stringify(functionResponse), // }); - // const chatFunctionCompletion = await openai.createChatCompletion(payload); + // const chatFunctionCompletion = await openai.chat.completions.create(payload); // // responseData = chatFunctionCompletion.data; - // log.debug(F, `chatFunctionCompletion: ${JSON.stringify(chatFunctionCompletion.data, null, 2)}`); + // log.debug(F, `chatFunctionCompletion: ${JSON.stringify(chatFunctionCompletion, null, 2)}`); - // if (chatFunctionCompletion.data.choices[0].message) { - // responseMessage = chatFunctionCompletion.data.choices[0].message; + // if (chatFunctionCompletion.choices[0].message) { + // responseMessage = chatFunctionCompletion.choices[0].message; // // Sum up the new tokens - // promptTokens += chatCompletion.data.usage?.prompt_tokens ?? 0; - // completionTokens += chatCompletion.data.usage?.completion_tokens ?? 0; + // promptTokens += chatCompletion.usage?.prompt_tokens ?? 0; + // completionTokens += chatCompletion.usage?.completion_tokens ?? 0; // } // } @@ -170,9 +229,7 @@ export default async function aiChat( } // responseData = chatCompletion.data; - // log.debug(F, `responseData: ${JSON.stringify(responseData, null, 2)}`); - - // log.debug(F, `response: ${response}`); + log.debug(F, `response: ${response}`); // Increment the tokens used await db.ai_personas.update({ @@ -195,23 +252,58 @@ export default async function aiChat( */ export async function aiModerate( message: string, -):Promise { - const moderation = await openai.moderations - .create({ - input: message, - }) - .catch(err => { - if (err instanceof OpenAI.APIError) { - log.error(F, `${err.status}`); // 400 - log.error(F, `${err.name}`); // BadRequestError + guildId: string, +):Promise { + const moderation = await aiModerateReport(message); - log.error(F, `${err.headers}`); // {server: 'nginx', ...} - } else { - throw err; - } - }); if (!moderation) { return []; } - return moderation.results; + + // log.debug(F, `moderation: ${JSON.stringify(moderation, null, 2)}`); + + const guildData = await db.discord_guilds.upsert({ + where: { + id: guildId, + }, + create: { + id: guildId, + }, + update: {}, + }); + + const guildModeration = await db.ai_moderation.upsert({ + where: { + guild_id: guildData.id, + }, + create: { + guild_id: guildData.id, + }, + update: {}, + }); + + // log.debug(F, `guildModeration: ${JSON.stringify(guildModeration, null, 2)}`); + + // Go through each key in moderation.results and check if the value is greater than the limit from guildModeration + // If it is, set a flag with the kind of alert and the value / limit + const moderationAlerts = [] as ModerationResult[]; + Object.entries(moderation.results[0].category_scores).forEach(([key, value]) => { + const formattedKey = key + .replace('/', '_') + .replace('-', '_'); + const guildLimit = guildModeration[formattedKey as keyof typeof guildModeration] as number; + + if (value > guildLimit) { + // log.debug(F, `key: ${formattedKey} value > ${value} / ${guildLimit} < guild limit`); + moderationAlerts.push({ + category: key, + value, + limit: (guildModeration[formattedKey as keyof typeof guildModeration] as number), + }); + } + }); + + // log.debug(F, `moderationAlerts: ${JSON.stringify(moderationAlerts, null, 2)}`); + + return moderationAlerts; } diff --git a/src/global/utils/env.config.ts b/src/global/utils/env.config.ts index f014e503b..4b92ba2ca 100644 --- a/src/global/utils/env.config.ts +++ b/src/global/utils/env.config.ts @@ -173,6 +173,7 @@ export const env = { CHANNEL_BOTERRORS: isProd ? '1081018048858824835' : '1081018727992152185', CHANNEL_DEVELOPMENTVOICE: isProd ? '970848692158464021' : '1052634132729036820', CHANNEL_AILOG: isProd ? '1137781426444574801' : '1137747287607623800', + CHANNEL_AIMOD_LOG: isProd ? '1169746977811091577' : '1169747347165687838', CATEGORY_RADIO: isProd ? '981069604665327646' : '1052634090819551436', CHANNEL_SYNTHWAVERADIO: isProd ? '1050099921811939359' : '1052634114693541898', diff --git a/src/prisma/tripbot/migrations/migration_lock.toml b/src/prisma/tripbot/migrations/migration_lock.toml new file mode 100644 index 000000000..fbffa92c2 --- /dev/null +++ b/src/prisma/tripbot/migrations/migration_lock.toml @@ -0,0 +1,3 @@ +# Please do not edit this file manually +# It should be added in your version-control system (i.e. Git) +provider = "postgresql" \ No newline at end of file diff --git a/src/prisma/tripbot/schema.prisma b/src/prisma/tripbot/schema.prisma index 913a82ac5..98d5859b9 100644 --- a/src/prisma/tripbot/schema.prisma +++ b/src/prisma/tripbot/schema.prisma @@ -189,6 +189,7 @@ model discord_guilds { appeals appeals[] counting counting[] reaction_roles reaction_roles[] + ai_moderation ai_moderation? } model drug_articles { @@ -416,6 +417,26 @@ model ai_channels { @@unique([channel_id, persona_id], map: "aichannels_channelid_personaid_unique") } +model ai_moderation { + id String @id @default(dbgenerated("uuid_generate_v4()")) @db.Uuid + guild_id String? + harassment Float @default(0.01) + harassment_threatening Float @default(0.01) @map("harassment/threatening") + hate Float @default(0.01) + hate_threatening Float @default(0.01) @map("hate/threatening") + self_harm Float @default(0.01) @map("self-harm") + self_harm_instructions Float @default(0.01) @map("self-harm/instructions") + self_harm_intent Float @default(0.01) @map("self-harm/intent") + sexual Float @default(0.01) + sexual_minors Float @default(0.01) @map("sexual/minors") + violence Float @default(0.01) + violence_graphic Float @default(0.01) @map("violence/graphic") + + discord_guilds discord_guilds? @relation(fields: [guild_id], references: [id], onDelete: NoAction, onUpdate: NoAction, map: "appeals_guildid_foreign") + + @@unique([guild_id], map: "aimoderation_guildid_unique") +} + enum bridge_status { PENDING ACTIVE @@ -528,10 +549,11 @@ enum appeal_status { } enum ai_model { - GPT_4 @map("GPT-4") - GPT_3_5_TURBO @map("GPT-3.5-TURBO") - DAVINCI - CURIE - BABBAGE - ADA + GPT_3_5_TURBO @map("GPT-3.5-TURBO") + GPT_3_5_TURBO_1106 @map("GPT-3.5-TURBO-1106") + GPT_4 @map("GPT-4") + GPT_4_1106_PREVIEW @map("GPT-4-1106-PREVIEW") + GPT_4_1106_VISION_PREVIEW @map("GPT-4-1106-VISION-PREVIEW") + DALL_E_2 @map("DALL-E-2") + DALL_E_3 @map("DALL-E-3") } From de519f9857caf30a65a168125eef9253e760ac56 Mon Sep 17 00:00:00 2001 From: LunaUrsa <1836049+LunaUrsa@users.noreply.github.com> Date: Wed, 15 Nov 2023 14:15:11 -0600 Subject: [PATCH 3/4] Add alerts when values are high --- src/discord/commands/guild/d.ai.ts | 7 +++++-- src/global/commands/g.ai.ts | 2 -- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/src/discord/commands/guild/d.ai.ts b/src/discord/commands/guild/d.ai.ts index a45f7953b..19229ebc1 100644 --- a/src/discord/commands/guild/d.ai.ts +++ b/src/discord/commands/guild/d.ai.ts @@ -1563,8 +1563,12 @@ export async function discordAiModerate( ); const modAiModifyButtons = [] as ActionRowBuilder[]; + let pingMessage = ''; // For each of the sortedCategoryScores, add a field modResults.forEach(result => { + if (result.value > 0.90) { + pingMessage = `Please review <@${env.DISCORD_OWNER_ID}>`; + } aiEmbed.addFields( { name: result.category, @@ -1636,10 +1640,9 @@ export async function discordAiModerate( // Get the channel to send the message to const channelAiModLog = await discordClient.channels.fetch(env.CHANNEL_AIMOD_LOG) as TextChannel; - // Send the message await channelAiModLog.send({ - content: `${targetMember} was flagged by AI for ${activeFlags.join(', ')} in ${messageData.url} <@${env.DISCORD_OWNER_ID}>`, + content: `${targetMember} was flagged by AI for ${activeFlags.join(', ')} in ${messageData.url} ${pingMessage}`, embeds: [aiEmbed], components: [userActions, ...modAiModifyButtons], }); diff --git a/src/global/commands/g.ai.ts b/src/global/commands/g.ai.ts index 677c8f414..6ee747b7c 100644 --- a/src/global/commands/g.ai.ts +++ b/src/global/commands/g.ai.ts @@ -1,5 +1,3 @@ -// alerts when something is higher than 90 - import OpenAI from 'openai'; import { PrismaClient, ai_personas } from '@prisma/client'; import { ModerationCreateResponse } from 'openai/resources'; From efe28e412e9bf379a88d4d9c26110fa063935a40 Mon Sep 17 00:00:00 2001 From: LunaUrsa <1836049+LunaUrsa@users.noreply.github.com> Date: Wed, 15 Nov 2023 14:36:29 -0600 Subject: [PATCH 4/4] New migration sql --- package.json | 2 +- .../migration.sql | 38 +++++++++++++++++++ src/prisma/tripbot/schema.prisma | 4 ++ 3 files changed, 43 insertions(+), 1 deletion(-) create mode 100644 src/prisma/tripbot/migrations/20231115203612_ai_mod_update/migration.sql diff --git a/package.json b/package.json index 535c9d60c..4ab9eca6f 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "db:validateSchema": "docker exec -it tripbot npx prisma validate", "db:generateClient": "npx prisma generate && docker exec -it tripbot npx prisma generate", "db:push": "docker exec -it tripbot npx prisma db push", - "db:migrateDev": "docker exec -it tripbot npx prisma migrate dev -n changeName", + "db:migrateDev": "docker exec -it tripbot npx prisma migrate dev -n aiModUpdate", "## PGADMIN ##": "", "pgadmin": "docker compose --project-name tripbot up -d --force-recreate --build tripbot_pgadmin", "pgadmin:logs": "docker container logs tripbot_pgadmin -f -n 100", diff --git a/src/prisma/tripbot/migrations/20231115203612_ai_mod_update/migration.sql b/src/prisma/tripbot/migrations/20231115203612_ai_mod_update/migration.sql new file mode 100644 index 000000000..9ef6a298c --- /dev/null +++ b/src/prisma/tripbot/migrations/20231115203612_ai_mod_update/migration.sql @@ -0,0 +1,38 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "ai_model" ADD VALUE 'GPT-3.5-TURBO-1106'; +ALTER TYPE "ai_model" ADD VALUE 'GPT-4-1106-PREVIEW'; +ALTER TYPE "ai_model" ADD VALUE 'GPT-4-1106-VISION-PREVIEW'; +ALTER TYPE "ai_model" ADD VALUE 'DALL-E-2'; +ALTER TYPE "ai_model" ADD VALUE 'DALL-E-3'; + +-- CreateTable +CREATE TABLE "ai_moderation" ( + "id" UUID NOT NULL DEFAULT uuid_generate_v4(), + "guild_id" TEXT, + "harassment" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "harassment/threatening" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "hate" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "hate/threatening" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "self-harm" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "self-harm/instructions" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "self-harm/intent" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "sexual" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "sexual/minors" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "violence" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + "violence/graphic" DOUBLE PRECISION NOT NULL DEFAULT 0.01, + + CONSTRAINT "ai_moderation_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "aimoderation_guildid_unique" ON "ai_moderation"("guild_id"); + +-- AddForeignKey +ALTER TABLE "ai_moderation" ADD CONSTRAINT "appeals_guildid_foreign" FOREIGN KEY ("guild_id") REFERENCES "discord_guilds"("id") ON DELETE NO ACTION ON UPDATE NO ACTION; diff --git a/src/prisma/tripbot/schema.prisma b/src/prisma/tripbot/schema.prisma index 98d5859b9..932967bf5 100644 --- a/src/prisma/tripbot/schema.prisma +++ b/src/prisma/tripbot/schema.prisma @@ -556,4 +556,8 @@ enum ai_model { GPT_4_1106_VISION_PREVIEW @map("GPT-4-1106-VISION-PREVIEW") DALL_E_2 @map("DALL-E-2") DALL_E_3 @map("DALL-E-3") + DAVINCI + CURIE + BABBAGE + ADA }