diff --git a/Commands/Tickets/ticket.js b/Commands/Tickets/ticket.js index 00fa9b2..11ca0b6 100644 --- a/Commands/Tickets/ticket.js +++ b/Commands/Tickets/ticket.js @@ -1,6 +1,3 @@ -// SupportBot | Emerald Services -// Ticket Command - const fs = require("fs"); const { EmbedBuilder, @@ -22,8 +19,12 @@ const yaml = require("js-yaml"); const db = require("../../Structures/Database.js"); -const panelconfig = yaml.load(fs.readFileSync("./Configs/ticket-panel.yml", "utf8")); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); +const panelconfig = yaml.load( + fs.readFileSync("./Configs/ticket-panel.yml", "utf8"), +); +const supportbot = yaml.load( + fs.readFileSync("./Configs/supportbot.yml", "utf8"), +); const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); @@ -33,7 +34,9 @@ const TicketNumberID = require("../../Structures/TicketID.js"); async function isBlacklisted(userId) { let blacklistData; try { - blacklistData = JSON.parse(fs.readFileSync("./Data/BlacklistedUsers.json", "utf8")); + blacklistData = JSON.parse( + fs.readFileSync("./Data/BlacklistedUsers.json", "utf8"), + ); } catch (error) { console.error("Error reading BlacklistedUsers.json:", error); return false; @@ -51,7 +54,9 @@ async function getClockedInUsers(guild) { const clockedInUsers = new Set(); if (typeof db.getProfile !== "function") { - console.error("db.getProfile is not a function. Check Structures/Database.js"); + console.error( + "db.getProfile is not a function. Check Structures/Database.js", + ); return clockedInUsers; } @@ -79,6 +84,14 @@ function getPrioritySystem() { return supportbot.Ticket?.PrioritySystem || {}; } +function getAIModeConfig() { + return supportbot.Ticket?.AIMode || {}; +} + +function isAIModeEnabledGlobally() { + return getAIModeConfig().Enabled === true; +} + function areDepartmentsEnabled() { return ( supportbot.Ticket?.TicketType !== "threads" && @@ -93,6 +106,34 @@ function arePrioritiesEnabled() { ); } +function getSelectedDepartmentKey(interaction) { + const departmentSystem = getDepartmentSystem(); + const departmentsEnabled = areDepartmentsEnabled(); + const departments = departmentSystem.Departments || {}; + + if (!departmentsEnabled) { + return departmentSystem.DefaultDepartment || "general"; + } + + const selectedDepartment = + interaction.options?.getString?.("department") || + interaction.department || + departmentSystem.DefaultDepartment || + "general"; + + if (departments[selectedDepartment]) { + return selectedDepartment; + } + + const firstDepartmentKey = Object.keys(departments)[0]; + return firstDepartmentKey || departmentSystem.DefaultDepartment || "general"; +} + +function getDepartmentConfig(departmentKey) { + if (!departmentKey) return null; + return getDepartmentSystem().Departments?.[departmentKey] || null; +} + function getPriorityKey(interaction, departmentKey) { if (!arePrioritiesEnabled()) { return null; @@ -110,37 +151,16 @@ function getPriorityKey(interaction, departmentKey) { ); } -function getDepartmentConfig(departmentKey) { - if (!departmentKey) return null; - return getDepartmentSystem().Departments?.[departmentKey] || null; -} - -function getPriorityKey(interaction, departmentKey) { - if (!arePrioritiesEnabled()) { - return null; - } - - const prioritySystem = getPrioritySystem(); - const departmentConfig = getDepartmentConfig(departmentKey); - - return ( - interaction.priority || - departmentConfig?.DefaultPriority || - prioritySystem.DefaultPriority || - "medium" - ); -} - function getPriorityConfig(priorityKey) { if (!priorityKey) return null; return getPrioritySystem().Priorities?.[priorityKey] || null; } function buildTicketChannelName(ticketNumberID, priorityKey, departmentConfig) { - const defaultPrefix = supportbot.Ticket?.ChannelPrefix || supportbot.Ticket?.Channel || "ticket-"; + const defaultPrefix = + supportbot.Ticket?.ChannelPrefix || supportbot.Ticket?.Channel || "ticket-"; const departmentPrefix = departmentConfig?.ChannelPrefix || defaultPrefix; - const safePrefix = String(departmentPrefix).trim() || "ticket-"; const baseName = `${safePrefix}${ticketNumberID}`; @@ -151,60 +171,114 @@ function buildTicketChannelName(ticketNumberID, priorityKey, departmentConfig) { return `${baseName}-${String(priorityKey).toLowerCase()}`; } +function buildAIModeButtons(ticketId, aiEnabled = false) { + const enableButton = new ButtonBuilder() + .setCustomId(`ticket_ai_enable:${ticketId}`) + .setLabel(msgconfig.Ticket.AIMode.EnableButton) + .setStyle(ButtonStyle.Success) + .setDisabled(aiEnabled); + + const disableButton = new ButtonBuilder() + .setCustomId(`ticket_ai_disable:${ticketId}`) + .setLabel(msgconfig.Ticket.AIMode.DisableButton) + .setStyle(ButtonStyle.Secondary) + .setDisabled(!aiEnabled); + + return new ActionRowBuilder().addComponents(enableButton, disableButton); +} + +async function createAITicketThread(parentTicketChannel, interaction) { + const threadName = supportbot.Ticket.AIMode?.ThreadName || "ai-chat"; + + const aiThread = await parentTicketChannel.threads.create({ + name: threadName, + autoArchiveDuration: 60, + reason: "ticket ai mode thread", + }); + + const welcomeEmbed = new EmbedBuilder() + .setTitle(msgconfig.Ticket.AIMode.ThreadWelcomeTitle) + .setDescription(msgconfig.Ticket.AIMode.ThreadWelcomeMessage) + .setColor(supportbot.Embed.Colours.General); + + await aiThread.send({ embeds: [welcomeEmbed] }); + + if (typeof db.setTicketAIState === "function") { + db.setTicketAIState(parentTicketChannel.id, { + enabled: true, + aiChannelId: aiThread.id, + aiType: "thread", + enabledBy: interaction.user.id, + enabledAt: new Date().toISOString(), + }); + } + + return aiThread; +} + async function askTicketQuestions(ticketChannel, interaction, questions) { - const answers = []; - let questionIndex = 0; + return new Promise(async (resolve) => { + const answers = []; + let questionIndex = 0; - const sendNextQuestion = async () => { - if (questionIndex < questions.length) { - const questionsEmbed = new EmbedBuilder() - .setDescription(`**${questionIndex + 1}.** ${questions[questionIndex]}`) - .setColor(supportbot.Embed.Colours.General); + const sendNextQuestion = async () => { + if (questionIndex < questions.length) { + const questionsEmbed = new EmbedBuilder() + .setDescription( + `**${questionIndex + 1}.** ${questions[questionIndex]}`, + ) + .setColor(supportbot.Embed.Colours.General); - const questionMessage = await ticketChannel.send({ - embeds: [questionsEmbed], - }); - - const collector = new MessageCollector(ticketChannel, { - filter: (m) => m.author.id === interaction.user.id, - max: 1, - time: 60000, - }); - - collector.on("collect", async (message) => { - answers.push({ - question: questions[questionIndex], - answer: message.content, + const questionMessage = await ticketChannel.send({ + embeds: [questionsEmbed], }); - questionIndex++; - await message.delete().catch(() => {}); - await questionMessage.delete().catch(() => {}); - sendNextQuestion(); - }); - } else { - const summaryEmbed = new EmbedBuilder() - .setTitle(msgconfig.Ticket.TicketQuestions.Details.Title) - .setDescription( - msgconfig.Ticket.TicketQuestions.Details.Description.replace( - "%user%", - interaction.user.id, - ), - ) - .setColor(supportbot.Embed.Colours.General) - .addFields( - answers.map((a) => ({ - name: `Q: ${a.question}`, - value: `A: ${a.answer}`, - inline: false, - })), - ) - .setTimestamp(); - await ticketChannel.send({ embeds: [summaryEmbed] }); - } - }; + const collector = new MessageCollector(ticketChannel, { + filter: (m) => m.author.id === interaction.user.id, + max: 1, + time: 60000, + }); - sendNextQuestion(); + collector.on("collect", async (message) => { + answers.push({ + question: questions[questionIndex], + answer: message.content, + }); + + questionIndex++; + + await message.delete().catch(() => {}); + await questionMessage.delete().catch(() => {}); + + sendNextQuestion(); + }); + } else { + const summaryEmbed = new EmbedBuilder() + .setTitle(msgconfig.Ticket.TicketQuestions.Details.Title) + .setDescription( + msgconfig.Ticket.TicketQuestions.Details.Description.replace( + "%user%", + interaction.user.id, + ), + ) + .setColor(supportbot.Embed.Colours.General) + .addFields( + answers.map((a) => ({ + name: `Q: ${a.question}`, + value: `A: ${a.answer}`, + inline: false, + })), + ) + .setTimestamp(); + + await ticketChannel.send({ embeds: [summaryEmbed] }); + + resolve(answers); + } + }; + + sendNextQuestion(); + }); } async function assignTicketToUser( @@ -243,7 +317,10 @@ async function assignTicketToUser( const claimEmbed = new EmbedBuilder() .setTitle(msgconfig.Ticket.ClaimTickets.ClaimTitle) .setDescription( - msgconfig.Ticket.ClaimTickets.ClaimMessage.replace("%user%", interaction.user.id), + msgconfig.Ticket.ClaimTickets.ClaimMessage.replace( + "%user%", + interaction.user.id, + ), ) .setColor(supportbot.Embed.Colours.General); @@ -294,11 +371,7 @@ async function assignTicketToUser( db.claimTicket(ticketChannel.id, i.user.id); } - await claimMessage - .edit({ - components: [], - }) - .catch(() => {}); + await claimMessage.edit({ components: [] }).catch(() => {}); }); collector.on("end", async (collected) => { @@ -308,7 +381,9 @@ async function assignTicketToUser( content: `Ticket is still unclaimed.\nCC: ${claimPing}`, embeds: [ new EmbedBuilder() - .setDescription(`Ticket is still unclaimed: <#${ticketChannel.id}>`) + .setDescription( + `Ticket is still unclaimed: <#${ticketChannel.id}>`, + ) .setColor(supportbot.Embed.Colours.Warn), ], }); @@ -319,9 +394,13 @@ async function assignTicketToUser( } const assignedUserId = - eligibleClockedInUsers[Math.floor(Math.random() * eligibleClockedInUsers.length)]; + eligibleClockedInUsers[ + Math.floor(Math.random() * eligibleClockedInUsers.length) + ]; - const assignedUser = await interaction.guild.members.fetch(assignedUserId).catch(() => null); + const assignedUser = await interaction.guild.members + .fetch(assignedUserId) + .catch(() => null); if (!assignedUser) { return null; @@ -351,18 +430,16 @@ async function assignTicketToUser( } await ticketChannel - .send({ content: `<@${assignedUserId}> has claimed the ticket.` }) + .send({ + content: `<@${assignedUserId}> has claimed the ticket.`, + }) .catch(() => {}); if (typeof db.claimTicket === "function") { db.claimTicket(ticketChannel.id, assignedUserId); } - await claimMessage - .edit({ - components: [], - }) - .catch(() => {}); + await claimMessage.edit({ components: [] }).catch(() => {}); }); collector.on("end", async (collected) => { @@ -374,7 +451,9 @@ async function assignTicketToUser( content: `No one claimed the ticket. Tagging roles: ${claimPing}`, embeds: [ new EmbedBuilder() - .setDescription(`Ticket is still unclaimed: <#${ticketChannel.id}>`) + .setDescription( + `Ticket is still unclaimed: <#${ticketChannel.id}>`, + ) .setColor(supportbot.Embed.Colours.Warn), ], }); @@ -397,10 +476,11 @@ async function assignTicketToUser( return hasAdmin || hasStaff || hasDepartmentRole; }; - const retryCollector = retryClaimMessage.createMessageComponentCollector({ - filter: retryFilter, - time: 120000, - }); + const retryCollector = + retryClaimMessage.createMessageComponentCollector({ + filter: retryFilter, + time: 120000, + }); retryCollector.on("collect", async (i) => { await i.deferUpdate().catch(() => {}); @@ -417,11 +497,7 @@ async function assignTicketToUser( db.claimTicket(ticketChannel.id, i.user.id); } - await retryClaimMessage - .edit({ - components: [], - }) - .catch(() => {}); + await retryClaimMessage.edit({ components: [] }).catch(() => {}); }); retryCollector.on("end", async (retryCollected) => { @@ -436,7 +512,10 @@ async function assignTicketToUser( const ReassigningEmbed = new EmbedBuilder() .setTitle(msgconfig.Ticket.ClaimTickets.ReassigningTitle) .setDescription( - msgconfig.Ticket.ClaimTickets.ReassigningMessage.replace("%user%", assignedUserId), + msgconfig.Ticket.ClaimTickets.ReassigningMessage.replace( + "%user%", + assignedUserId, + ), ) .setColor(supportbot.Embed.Colours.General); @@ -513,7 +592,7 @@ module.exports = new Command({ ], permissions: cmdconfig.OpenTicket.Permission, - + async run(interaction) { try { if (await isBlacklisted(interaction.user.id)) { @@ -525,7 +604,10 @@ module.exports = new Command({ let TicketReason = null; if (supportbot.Ticket.TicketReason) { - TicketReason = interaction.options?.getString?.("reason") || interaction.reason || null; + TicketReason = + interaction.options?.getString?.("reason") || + interaction.reason || + null; } let TicketData = { tickets: db.getAllTickets() }; @@ -534,8 +616,9 @@ module.exports = new Command({ if ( supportbot.Ticket.TicketsPerUser && - TicketData.tickets.filter((t) => t.user === interaction.user.id && t.open).length >= - supportbot.Ticket.TicketsPerUser + TicketData.tickets.filter( + (t) => t.user === interaction.user.id && t.open, + ).length >= supportbot.Ticket.TicketsPerUser ) { return interaction.reply({ embeds: [ @@ -552,12 +635,21 @@ module.exports = new Command({ const usingThreads = supportbot.Ticket.TicketType === "threads"; const departmentsEnabled = areDepartmentsEnabled(); const prioritiesEnabled = arePrioritiesEnabled(); + const aiModeEnabled = isAIModeEnabledGlobally(); - const departmentKey = departmentsEnabled ? getSelectedDepartmentKey(interaction) : null; - const departmentConfig = departmentsEnabled ? getDepartmentConfig(departmentKey) : null; + const departmentKey = departmentsEnabled + ? getSelectedDepartmentKey(interaction) + : null; + const departmentConfig = departmentsEnabled + ? getDepartmentConfig(departmentKey) + : null; - const priorityKey = prioritiesEnabled ? getPriorityKey(interaction, departmentKey) : null; - const priorityConfig = prioritiesEnabled ? getPriorityConfig(priorityKey) : null; + const priorityKey = prioritiesEnabled + ? getPriorityKey(interaction, departmentKey) + : null; + const priorityConfig = prioritiesEnabled + ? getPriorityConfig(priorityKey) + : null; let ticketNumberID = await TicketNumberID.pad(); const TicketSubject = TicketReason || msgconfig.Ticket.InvalidSubject; @@ -582,17 +674,26 @@ module.exports = new Command({ }); } - const Staff = await getRole(supportbot.Roles.StaffMember.Staff, interaction.guild); - const Admin = await getRole(supportbot.Roles.StaffMember.Admin, interaction.guild); + const Staff = await getRole( + supportbot.Roles.StaffMember.Staff, + interaction.guild, + ); + const Admin = await getRole( + supportbot.Roles.StaffMember.Admin, + interaction.guild, + ); - const departmentRoleId = departmentsEnabled ? departmentConfig?.Role || null : null; + const departmentRoleId = departmentsEnabled + ? departmentConfig?.Role || null + : null; const DepartmentRole = departmentRoleId ? await getRole(departmentRoleId, interaction.guild) : null; if (!Staff || !Admin) { return interaction.reply({ - content: "Some roles seem to be missing!\nPlease check for errors when starting the bot.", + content: + "Some roles seem to be missing!\nPlease check for errors when starting the bot.", flags: MessageFlags.Ephemeral, }); } @@ -601,10 +702,15 @@ module.exports = new Command({ if (usingThreads) { const TicketHome = interaction.guild.channels.cache.find( - (c) => c.name === supportbot.Ticket.TicketHome || c.id === supportbot.Ticket.TicketHome, + (c) => + c.name === supportbot.Ticket.TicketHome || + c.id === supportbot.Ticket.TicketHome, ); - const channel = await getChannel(supportbot.Ticket.TicketHome, interaction.guild); + const channel = await getChannel( + supportbot.Ticket.TicketHome, + interaction.guild, + ); ticketChannel = await channel.threads.create({ name: ticketChannelName, @@ -613,7 +719,7 @@ module.exports = new Command({ autoArchiveDuration: 60, reason: "support ticket", }); - } else if (supportbot.Ticket.TicketType === "channels") { + } else { let targetCategoryId = supportbot.Ticket.TicketChannelsCategory; if (departmentsEnabled && departmentConfig?.Category) { @@ -638,7 +744,11 @@ module.exports = new Command({ reason: "support ticket", }); - if (!departmentsEnabled && category.children.cache && category.children.cache.size >= 50) { + if ( + !departmentsEnabled && + category.children.cache && + category.children.cache.size >= 50 + ) { const secondaryCategory = interaction.guild.channels.cache.find( (c) => c.name === supportbot.Ticket.TicketChannelsCategory2 || @@ -657,12 +767,18 @@ module.exports = new Command({ } if (supportbot.Ticket.ClaimTickets.Enabled) { - await assignTicketToUser(ticketChannel, Staff, Admin, interaction, DepartmentRole || null); + await assignTicketToUser( + ticketChannel, + Staff, + Admin, + interaction, + DepartmentRole || null, + ); } if (usingThreads) { await ticketChannel.members.add(interaction.user.id); - } else if (supportbot.Ticket.TicketType === "channels") { + } else { await ticketChannel.permissionOverwrites.create(interaction.user.id, { ViewChannel: true, SendMessages: true, @@ -689,9 +805,12 @@ module.exports = new Command({ }); } - await ticketChannel.permissionOverwrites.create(interaction.guild.roles.everyone.id, { - ViewChannel: false, - }); + await ticketChannel.permissionOverwrites.create( + interaction.guild.roles.everyone.id, + { + ViewChannel: false, + }, + ); } db.addTicket({ @@ -706,13 +825,19 @@ module.exports = new Command({ claimedAt: null, department: departmentKey, priority: priorityKey, + aiModeEnabled: !usingThreads && aiModeEnabled, + aiChannelId: null, + questionAnswers: [], }); TicketData.tickets = db.getAllTickets(); const CreatedTicket = new EmbedBuilder() .setDescription( - msgconfig.Ticket.TicketCreatedAlert.replace(/%ticketauthor%/g, interaction.user.id) + msgconfig.Ticket.TicketCreatedAlert.replace( + /%ticketauthor%/g, + interaction.user.id, + ) .replace(/%ticketid%/g, ticketChannel.id) .replace(/%ticketusername%/g, interaction.user.username) .replace(/%ticketreason%/g, TicketSubject), @@ -733,7 +858,10 @@ module.exports = new Command({ } const ticketAuthor = new TextDisplayBuilder().setContent( - msgconfig.Ticket.TicketAuthorTitle.replace(/%ticketauthor%/g, interaction.user.id) + msgconfig.Ticket.TicketAuthorTitle.replace( + /%ticketauthor%/g, + interaction.user.id, + ) .replace(/%ticketid%/g, ticketChannel.id) .replace(/%ticketusername%/g, interaction.user.username), ); @@ -741,7 +869,10 @@ module.exports = new Command({ ticketMsgContainer.addTextDisplayComponents(ticketAuthor); const ticketTitle = new TextDisplayBuilder().setContent( - msgconfig.Ticket.TicketTitle.replace(/%ticketauthor%/g, interaction.user.id) + msgconfig.Ticket.TicketTitle.replace( + /%ticketauthor%/g, + interaction.user.id, + ) .replace(/%ticketid%/g, ticketChannel.id) .replace(/%ticketusername%/g, interaction.user.username), ); @@ -752,7 +883,10 @@ module.exports = new Command({ ticketMsgContainer.addSeparatorComponents(seperator2); const ticketDescription = new TextDisplayBuilder().setContent( - msgconfig.Ticket.TicketMessage.replace(/%ticketauthor%/g, interaction.user.id) + msgconfig.Ticket.TicketMessage.replace( + /%ticketauthor%/g, + interaction.user.id, + ) .replace(/%ticketid%/g, ticketChannel.id) .replace(/%ticketusername%/g, interaction.user.username) .replace(/%ticketreason%/g, TicketSubject), @@ -764,7 +898,9 @@ module.exports = new Command({ ticketMsgContainer.addSeparatorComponents(seperator3); if (supportbot.Ticket.TicketReason && TicketReason) { - const ticketReason = new TextDisplayBuilder().setContent(`**Reason:**\n${TicketReason}`); + const ticketReason = new TextDisplayBuilder().setContent( + `**Reason:**\n${TicketReason}`, + ); ticketMsgContainer.addTextDisplayComponents(ticketReason); } @@ -790,6 +926,24 @@ module.exports = new Command({ ticketMsgContainer.addTextDisplayComponents(priorityText); } + if (usingThreads && aiModeEnabled) { + const aiSeparator = new SeparatorBuilder().setDivider(true); + ticketMsgContainer.addSeparatorComponents(aiSeparator); + + const aiTitleText = new TextDisplayBuilder().setContent( + `**${msgconfig.Ticket.AIMode.Title}**`, + ); + ticketMsgContainer.addTextDisplayComponents(aiTitleText); + + const aiBodyText = new TextDisplayBuilder().setContent( + msgconfig.Ticket.AIMode.Description, + ); + ticketMsgContainer.addTextDisplayComponents(aiBodyText); + + const aiRow = buildAIModeButtons(ticketChannel.id, false); + ticketMsgContainer.addActionRowComponents(aiRow); + } + const selectMenuRow = new ActionRowBuilder(); const SelectMenus = new StringSelectMenuBuilder() @@ -851,7 +1005,9 @@ module.exports = new Command({ }); const departmentQuestions = - departmentsEnabled && departmentConfig?.Questions ? departmentConfig.Questions : []; + departmentsEnabled && departmentConfig?.Questions + ? departmentConfig.Questions + : []; const globalQuestionsEnabled = supportbot.Ticket.Questions?.Enabled; const globalQuestions = supportbot.Ticket.Questions?.List || []; @@ -864,8 +1020,31 @@ module.exports = new Command({ questionsToAsk = globalQuestions; } + let ticketQuestionsAndAnswers = []; + if (questionsToAsk.length > 0) { - await askTicketQuestions(ticketChannel, interaction, questionsToAsk); + ticketQuestionsAndAnswers = await askTicketQuestions( + ticketChannel, + interaction, + questionsToAsk, + ); + + if (typeof db.updateTicketQuestionAnswers === "function") { + db.updateTicketQuestionAnswers( + ticketChannel.id, + ticketQuestionsAndAnswers, + ); + } + } + + if (!usingThreads && aiModeEnabled) { + const aiThread = await createAITicketThread( + ticketChannel, + interaction, + ).catch((error) => { + console.error("Error creating AI mode thread:", error); + return null; + }); } } catch (error) { console.error("Error in run method:", error); diff --git a/Configs/messages.yml b/Configs/messages.yml index 1d3504d..edee95a 100644 --- a/Configs/messages.yml +++ b/Configs/messages.yml @@ -173,6 +173,22 @@ Ticket: Title: "Ticket Details" Description: "Questions by <@%user%>" # Use <@%user%> to display the username. + AIMode: + Title: "SupportBot AI" + Description: "Use the buttons below to enable or disable AI chat in this ticket." + EnabledMessage: "AI Mode has been enabled for this ticket." + DisabledMessage: "AI Mode has been disabled for this ticket." + + ThreadWelcomeTitle: "SupportBot AI" + ThreadWelcomeMessage: "Hi! I’m SupportBot AI. You can chat with me here about your issue." + + EnableButton: "Enable AI Mode" + DisableButton: "Disable AI Mode" + + TicketNotFound: "Ticket not found." + EnableError: "There was an error enabling SupportBot AI." + DisableError: "There was an error disabling SupportBot AI." + # ---------------------------- # Review Messages # ---------------------------- @@ -202,17 +218,6 @@ ReviewSystem: ReviewEmoji: "⭐" Color: "#f7a943" -# ---------------------------- -# Profile Messages -# ---------------------------- - -TicketStats: - OpenTickets: "āœ… Open Tickets" - TotalTickets: "šŸŽ« Total Tickets" - ResponseTime: "ā±ļø Average Response Time" - - Title: "Your Ticket Stats" - # ---------------------------- # Mod Command Messages # ---------------------------- diff --git a/Configs/supportbot-ai.yml b/Configs/supportbot-ai.yml index 1be9da0..5ebc8ba 100644 --- a/Configs/supportbot-ai.yml +++ b/Configs/supportbot-ai.yml @@ -8,32 +8,58 @@ Enabled: true General: + Provider: "openai" + Model: "gpt-5.4" + Model_API_Key: "MODEL_API_KEY" + # Providers Available: + # "openai" - Use OpenAI's GPT models. Example: "gpt-5.4" "gpt-5-mini" + # "groq" - Use Groq's GroqAI models. Example: "meta-llama/llama-4-scout-17b-16e-instruct" + + Tokens: 1200 + + Temperature: 0.4 Name: "SupportBot AI" - # What would you like to call your AI - Model: "gpt-5.4" - # You can see a list of OpenAI Models here: https://platform.openai.com/docs/models - - Tokens: 400 - # Max tokens you can set is 400 - # For more information about tokens, go to https://openai.com/api/pricing/ - OpenAI_Key: "OPEN_AI_API_KEY" - # To create an OpenAI API key, go to https://platform.openai.com/api-keys - PastebinAPI_Key: "PASTEBIN_API_KEY" - # You can get your Pastebin API Key from https://pastebin.com - PastebinAPI_URL: "https://pastebin.com/api/api_post.php" + PastebinAPI_URL: "https://pastebin.com/api/api_post.php" + +Style: + MessageType: "text" + # Available Styles for AI: + # "embed" - AI responses will be sent as Discord embeds. + # "text" - AI responses will be sent as plain messages. + # "components_v2" - AI responses will be sent with the new components style. + +Features: + SuggestTickets: true # Should SupportBot AI suggest creating a ticket if the user needs more help? Channels: - AIChannel: "AI_BOT_CHANNEL" - # This is the channel where AI will respond in without needing to mention the bot. + AIChannel: "CHANNEL_ID" Embed: - Color: 0x0cc559 # 0x - Footer: "SupportBot | Developed by Emerald Development" + Color: "#45b463" + Footer: "SupportBot AI" + +Memory: + Enabled: true + StoreFacts: true + StoreSummaries: true + MaxRecentMessages: 15 + +CustomInstructions: + - "You are SupportBot AI made by Emerald Services" + - "You are built with DiscordJS/NodeJS and currently running on dev build 26.0 build 2" + - "You're current build can be found here https://github.com/Emerald-Services/SupportBot/tree/26.0" + - "You're main build can be found here https://builtbybit.com/resources/supportbot-free-advanced-ticket-bot.8940/ and Polymart at https://polymart.org/product/518/supportbot-1-discord-ticket-bot which is version 8.4" + - "dev build 26.0 build 2 includes all-new AI features for SupportBot such as memory system that allows you to remember facts and summaries of conversations, which can be used to provide better responses." + - "SupportBot AI also have a new tickets AI Mode which is enabled in supportbot.ymll, which allows you to assist users in support tickets and provide better responses based on the context of the ticket." + - "Your main purpose is to assist users in the support server and provide helpful responses to their questions and issues. Always be accurate and if you do not have answer then simply ask the person to create a ticket if they need more help." + #- "Add custom instructions here" Messages: - MessageTooLong: "The response was too long, so I've posted it here: {pastebin}" - ErrorResponse: "I didn't receive a proper response from OpenAI. Please try again later." - OpenAIError: "I'm having some trouble talking to SupportBot AI's Model, OpenAI. Please try again later." - AIDisabled: "I am disabled, You have enable me in `supportbot-ai.yml`" \ No newline at end of file + ErrorResponse: "I couldn't generate a response." + AIError: "There was an error contacting the AI provider." + AIDisabled: "AI is currently disabled." + MessageTooLong: "My response was too long, so I uploaded it here: {pastebin}" + SuggestTicketText: "-# Need more help? React with šŸŽ« to open a ticket." + SuggestTicketReaction : "šŸŽ«" \ No newline at end of file diff --git a/Configs/supportbot.yml b/Configs/supportbot.yml index e42461e..d50e5c9 100644 --- a/Configs/supportbot.yml +++ b/Configs/supportbot.yml @@ -13,7 +13,7 @@ # # SupportBot Configuration (supportbot.yml) -SupportBot_Version: "v26.0.0-prerelease-1" +SupportBot_Version: "v26.0.0-prerelease-2" # ------------------------- # General @@ -144,7 +144,7 @@ VoiceTickets: Category: "CATEGORY_ID" Ticket: - TicketType: "threads" + TicketType: "channels" # Available TicketTypes: # channels - Tickets will be created as channels in the TicketChannelsCategory @@ -189,7 +189,7 @@ Ticket: StaffOnly: true ClaimTickets: - Enabled: true + Enabled: false Channel: "CHANNEL_ID" # Channel where the ticket assignments are sent for staff to claim. @@ -212,12 +212,12 @@ Ticket: # 4 - Red ReviewSystem: - Enabled: true + Enabled: false Channel: "CHANNEL_ID" UseModal: true - Questions: - Enabled: true # Set to false to disable ticket questions + Questions: # Default questions asked when a ticket is created, these are used if departments are disabled. + Enabled: false List: - "Please provide your account email?" - "What is the nature of your issue?" @@ -228,12 +228,17 @@ Ticket: TickeDataTitle: "Ticket Data" TicketBlacklistLog: "CHANNEL_ID" + AIMode: + Enabled: true + ThreadName: "Chat with SupportBot AI" + WelcomeMessage: "Hi! I’m SupportBot AI. You can chat with me here about your issue." + # ------------------------- # Ticket Departments # ------------------------- DepartmentSystem: - Enabled: true + Enabled: false Placeholder: "Choose a ticket department" DefaultDepartment: "general" @@ -286,7 +291,7 @@ Ticket: - "Anything else staff should know?" PrioritySystem: - Enabled: true + Enabled: false DefaultPriority: "Normal" AllowUsersToChooseOnOpen: true Priorities: diff --git a/Events/Models/groq.js b/Events/Models/groq.js new file mode 100644 index 0000000..8eab0ca --- /dev/null +++ b/Events/Models/groq.js @@ -0,0 +1,72 @@ +// Emerald Services | SupportBot +// Groq integration for SupportBot AI + +const fs = require("fs"); +const yaml = require("js-yaml"); +const OpenAI = require("openai"); + +const supportbotai = yaml.load( + fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"), +); + +const client = new OpenAI({ + apiKey: supportbotai.General.Model_API_Key, + baseURL: "https://api.groq.com/openai/v1", +}); + +function extractTextFromMessage(message) { + if (!message) return ""; + + if (typeof message.content === "string") { + return message.content.trim(); + } + + if (Array.isArray(message.content)) { + return message.content + .map((part) => { + if (typeof part === "string") return part; + if (part?.type === "text" && typeof part.text === "string") { + return part.text; + } + if (typeof part?.text?.value === "string") { + return part.text.value; + } + return ""; + }) + .join("") + .trim(); + } + + return ""; +} + +async function chat({ messages, temperature, maxTokens }) { + const model = supportbotai.General.Model; + + const response = await client.chat.completions.create({ + model, + messages, + temperature: temperature || supportbotai.General.Temperature || 0.4, + max_tokens: maxTokens || supportbotai.General.Tokens || 800, + }); + + const choice = response?.choices?.[0]; + const message = choice?.message; + const content = extractTextFromMessage(message); + + if (!content) { + console.error("Groq raw response:", JSON.stringify(response, null, 2)); + throw new Error( + `Groq returned empty response. finish_reason=${choice?.finish_reason || "unknown"}`, + ); + } + + return { + content, + raw: response, + }; +} + +module.exports = { + chat, +}; diff --git a/Events/Models/openai.js b/Events/Models/openai.js new file mode 100644 index 0000000..0df490f --- /dev/null +++ b/Events/Models/openai.js @@ -0,0 +1,68 @@ +// Emerald Services | SupportBot +// Open AI integration for SupportBot AI + +const fs = require("fs"); +const yaml = require("js-yaml"); +const OpenAI = require("openai"); + +const supportbotai = yaml.load( + fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"), +); + +const client = new OpenAI({ + apiKey: supportbotai.General.Model_API_Key, +}); + +function extractTextFromMessage(message) { + if (!message) return ""; + + if (typeof message.content === "string") { + return message.content.trim(); + } + + if (Array.isArray(message.content)) { + return message.content + .map((part) => { + if (typeof part === "string") return part; + if (part?.type === "text" && typeof part.text === "string") + return part.text; + if (typeof part?.text?.value === "string") return part.text.value; + return ""; + }) + .join("") + .trim(); + } + + return ""; +} + +async function chat({ messages, temperature, maxTokens }) { + const model = supportbotai.General.Model; + + const response = await client.chat.completions.create({ + model, + messages, + temperature: temperature || supportbotai.General.Temperature || 0.4, + max_completion_tokens: maxTokens || supportbotai.General.Tokens || 800, + }); + + const choice = response?.choices?.[0]; + const message = choice?.message; + const content = extractTextFromMessage(message); + + if (!content) { + console.error("OpenAI raw response:", JSON.stringify(response, null, 2)); + throw new Error( + `OpenAI returned empty response. finish_reason=${choice?.finish_reason || "unknown"}`, + ); + } + + return { + content, + raw: response, + }; +} + +module.exports = { + chat, +}; diff --git a/Events/interactionCreate.js b/Events/interactionCreate.js index 62c66eb..54b32d2 100644 --- a/Events/interactionCreate.js +++ b/Events/interactionCreate.js @@ -479,6 +479,127 @@ module.exports = new Event("interactionCreate", async (client, interaction) => { } if (interaction.isButton()) { + if (interaction.customId.startsWith("ticket_ai_enable:")) { + const ticketId = interaction.customId.split(":")[1]; + const ticket = db.getTicket(ticketId); + + if (!ticket) { + return interaction.reply({ + content: msgconfig.Ticket.AIMode.TicketNotFound, + flags: Discord.MessageFlags.Ephemeral, + }); + } + + try { + const usingThreads = supportbot.Ticket.TicketType === "threads"; + + if (usingThreads) { + db.setTicketAIState(ticketId, { + enabled: true, + aiChannelId: interaction.channel.id, + aiType: "thread", + enabledBy: interaction.user.id, + enabledAt: new Date().toISOString(), + }); + + const embed = new Discord.EmbedBuilder() + .setDescription(msgconfig.Ticket.AIMode.EnabledMessage) + .setColor(supportbot.Embed.Colours.Success); + + return interaction.reply({ + embeds: [embed], + flags: Discord.MessageFlags.Ephemeral, + }); + } + + let aiChannelId = ticket.aiChannelId; + + if (!aiChannelId) { + const threadName = + supportbot.Ticket.AIMode?.ThreadName || "Chat with SupportBot AI"; + + const aiThread = await interaction.channel.threads.create({ + name: threadName, + autoArchiveDuration: 60, + reason: "ticket ai mode thread", + }); + + await aiThread.members.add(interaction.user.id).catch(() => {}); + + aiChannelId = aiThread.id; + + const welcomeEmbed = new Discord.EmbedBuilder() + .setTitle(msgconfig.Ticket.AIMode.ThreadWelcomeTitle) + .setDescription(msgconfig.Ticket.AIMode.ThreadWelcomeMessage) + .setColor(supportbot.Embed.Colours.General); + + await aiThread.send({ embeds: [welcomeEmbed] }); + } + + db.setTicketAIState(ticketId, { + enabled: true, + aiChannelId, + aiType: "thread", + enabledBy: interaction.user.id, + enabledAt: new Date().toISOString(), + }); + + const embed = new Discord.EmbedBuilder() + .setDescription(msgconfig.Ticket.AIMode.EnabledMessage) + .setColor(supportbot.Embed.Colours.Success); + + return interaction.reply({ + embeds: [embed], + flags: Discord.MessageFlags.Ephemeral, + }); + } catch (error) { + console.error("Error enabling ticket AI:", error); + + return interaction.reply({ + content: msgconfig.Ticket.AIMode.EnableError, + flags: Discord.MessageFlags.Ephemeral, + }); + } + } + + if (interaction.customId.startsWith("ticket_ai_disable:")) { + const ticketId = interaction.customId.split(":")[1]; + const ticket = db.getTicket(ticketId); + + if (!ticket) { + return interaction.reply({ + content: msgconfig.Ticket.AIMode.TicketNotFound, + flags: Discord.MessageFlags.Ephemeral, + }); + } + + try { + db.setTicketAIState(ticketId, { + enabled: false, + aiChannelId: null, + aiType: null, + enabledBy: interaction.user.id, + enabledAt: null, + }); + + const embed = new Discord.EmbedBuilder() + .setDescription(msgconfig.Ticket.AIMode.DisabledMessage) + .setColor(supportbot.Embed.Colours.Success); + + return interaction.reply({ + embeds: [embed], + flags: Discord.MessageFlags.Ephemeral, + }); + } catch (error) { + console.error("Error disabling ticket AI:", error); + + return interaction.reply({ + content: msgconfig.Ticket.AIMode.DisableError, + flags: Discord.MessageFlags.Ephemeral, + }); + } + } + if (interaction.customId === "unarchiveTicket") { if (supportbot.Ticket.TicketType === "threads") { await interaction.channel.setArchived(false); diff --git a/Events/messageCreate.js b/Events/messageCreate.js index 537efdd..9302511 100644 --- a/Events/messageCreate.js +++ b/Events/messageCreate.js @@ -2,165 +2,534 @@ const fs = require("fs"); const Discord = require("discord.js"); const yaml = require("js-yaml"); const axios = require("axios"); -const { OpenAI } = require('openai'); -// Load configuration files with validation -function loadConfig(path) { - try { - return yaml.load(fs.readFileSync(path, "utf8")); - } catch (error) { - console.error(`Error loading config file at ${path}:`, error); - throw error; - } -} +const supportbotai = yaml.load( + fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"), +); +const supportbot = yaml.load( + fs.readFileSync("./Configs/supportbot.yml", "utf8"), +); -const supportbotai = yaml.load(fs.readFileSync("./Configs/supportbot-ai.yml")); -const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8")); -const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); -const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8")); -// Validate configuration values -function validateConfig(config, keys) { - keys.forEach(key => { - if (!config[key]) { - throw new Error(`Missing configuration key: ${key}`); - } - }); -} - -const CHANNELS = [supportbotai.Channels.AIChannel]; - -// Initialize Discord client -const client = new Discord.Client({ - intents: ['Guilds', 'GuildMembers', 'GuildMessages'] -}); - -// Load custom Event structure const Event = require("../Structures/Event.js"); +const AIDatabase = require("../Structures/AIDatabase.js"); +const db = require("../Structures/Database.js"); -// Initialize OpenAI client -const openai = new OpenAI({ - apiKey: supportbotai.General.OpenAI_Key, -}); +const openai = require("./Models/openai.js"); +const groq = require("./Models/groq.js"); const PASTEBIN_API_KEY = supportbotai.General.PastebinAPI_Key; const PASTEBIN_API_URL = supportbotai.General.PastebinAPI_URL; -// Function to create a paste on Pastebin +const CHANNELS = [supportbotai.Channels.AIChannel].filter(Boolean); + async function createPaste(content) { - try { - const response = await axios.post(PASTEBIN_API_URL, new URLSearchParams({ - api_dev_key: PASTEBIN_API_KEY, - api_option: 'paste', - api_paste_code: content, - api_paste_private: 1, // 0 = public, 1 = unlisted, 2 = private - api_paste_expire_date: '1D', // Expire in 1 day - api_paste_format: 'text' - })); - return response.data; - } catch (error) { - console.error('Pastebin Error:', error); - throw error; - } + try { + if (!PASTEBIN_API_KEY || !PASTEBIN_API_URL) return null; + + const response = await axios.post( + PASTEBIN_API_URL, + new URLSearchParams({ + api_dev_key: PASTEBIN_API_KEY, + api_option: "paste", + api_paste_code: content, + api_paste_private: 1, + api_paste_expire_date: "1D", + api_paste_format: "text", + }), + ); + + return response.data; + } catch (error) { + console.error("Pastebin Error:", error); + return null; + } +} + +function getProvider() { + const provider = String( + supportbotai.General.Provider || "openai", + ).toLowerCase(); + + switch (provider) { + case "openai": + return openai; + + case "groq": + return groq; + + default: + throw new Error(`Unsupported AI provider: ${provider}`); + } +} + +function formatTicketQuestionAnswers(questionAnswers) { + if (!Array.isArray(questionAnswers) || questionAnswers.length === 0) { + return "No ticket question answers were saved."; + } + + return questionAnswers + .map((item, index) => { + const question = item?.question || `Question ${index + 1}`; + const answer = item?.answer || "No answer provided"; + return `${index + 1}. ${question}\nAnswer: ${answer}`; + }) + .join("\n\n"); +} + +function buildTicketContext(ticket, message) { + if (!ticket) return ""; + + return ` +Ticket Information: +Ticket ID: ${ticket.ticket_id || ticket.id || "Unknown"} +Subject: ${ticket.subject || ticket.reason || "No subject provided"} +Department: ${ticket.department || "Not set"} +Priority: ${ticket.priority || "Not set"} +Created By: ${ticket.user_id || ticket.user || "Unknown"} +Current User: ${message?.author?.tag || "Unknown"} (${message?.author?.id || "Unknown"}) + +Ticket Question Answers: +${formatTicketQuestionAnswers(ticket.questionAnswers)} + +Use this information when helping the user with their ticket. +Do not invent missing ticket details. +`; +} + +function buildSystemPrompt(message) { + const sections = []; + + if (Array.isArray(supportbotai.CustomInstructions)) { + sections.push(...supportbotai.CustomInstructions); + } + + sections.push( + `The current guild is: ${message.guild?.name || "Unknown Guild"}`, + ); + sections.push( + `The current channel is: #${message.channel?.name || "unknown-channel"}`, + ); + + return sections.join("\n- ").replace(/^/, "- "); +} + +function buildConversationFromMemory(channelId, currentMessage) { + const memoryConfig = supportbotai.Memory || {}; + const memoryEnabled = memoryConfig.Enabled !== false; + const maxRecentMessages = memoryConfig.MaxRecentMessages || 15; + + const conversation = [ + { + role: "system", + content: buildSystemPrompt(currentMessage), + }, + ]; + + if (!memoryEnabled) return conversation; + + const recentMessages = AIDatabase.getRecentMessages( + channelId, + maxRecentMessages, + ); + + const latestSummary = + memoryConfig.StoreSummaries !== false + ? AIDatabase.getLatestSummary(channelId) + : null; + + const facts = + memoryConfig.StoreFacts !== false ? AIDatabase.getFacts(channelId) : []; + + const userFacts = + memoryConfig.StoreFacts !== false && + typeof AIDatabase.getUserFacts === "function" + ? AIDatabase.getUserFacts( + currentMessage.guild?.id, + currentMessage.author?.id, + ) + : []; + + if (latestSummary?.summary) { + conversation.push({ + role: "system", + content: `Conversation summary:\n${latestSummary.summary}`, + }); + } + + if (facts.length > 0) { + const factText = facts + .map( + (fact) => `- [${fact.fact_type}] ${fact.fact_key}: ${fact.fact_value}`, + ) + .join("\n"); + + conversation.push({ + role: "system", + content: `Known facts in this chat:\n${factText}`, + }); + } + + if (userFacts.length > 0) { + const userFactText = userFacts + .map( + (fact) => `- [${fact.fact_type}] ${fact.fact_key}: ${fact.fact_value}`, + ) + .join("\n"); + + conversation.push({ + role: "system", + content: `Known facts about this user across chats:\n${userFactText}`, + }); + } + + for (const msg of recentMessages) { + conversation.push({ + role: msg.role === "assistant" ? "assistant" : "user", + content: msg.content, + }); + } + + return conversation; +} + +function getTicketContextForMessage(message) { + if (!message?.channel) return null; + + if (typeof db.getTicketByAIChannel === "function") { + const aiTicket = db.getTicketByAIChannel(message.channel.id); + if (aiTicket) return aiTicket; + } + + if (typeof db.getTicket === "function") { + const directTicket = db.getTicket(message.channel.id); + if (directTicket) return directTicket; + } + + if (message.channel.isThread() && typeof db.getTicket === "function") { + const parentTicket = db.getTicket(message.channel.parentId); + if (parentTicket) return parentTicket; + } + + return null; +} + +function getAIMessageType() { + return String(supportbotai.Style?.MessageType || "embed").toLowerCase(); +} + +function shouldSuggestTicket(message, ticket) { + if (supportbotai.Features?.SuggestTickets !== true) return false; + if (ticket) return false; + if (message.channel.isThread()) return false; + return CHANNELS.includes(message.channel.id); +} + +function getSuggestTicketText() { + return ( + supportbotai.Messages?.SuggestTicketText || + "-# Need more help? React with šŸŽ« to open a ticket." + ); +} + +async function sendAIResponse({ + client, + message, + replyContent, + suggestTicket = false, +}) { + const messageType = getAIMessageType(); + const ticketHint = suggestTicket ? `\n\n${getSuggestTicketText()}` : ""; + + if (messageType === "text") { + return message.reply({ + content: `${replyContent}${ticketHint}`, + }); + } + + if (messageType === "components_v2") { + const { + ContainerBuilder, + TextDisplayBuilder, + SeparatorBuilder, + MessageFlags, + } = Discord; + + const container = new ContainerBuilder(); + + if (supportbot.Embed?.Colours?.General) { + container.setAccentColor( + parseInt(String(supportbot.Embed.Colours.General).replace("#", ""), 16), + ); + } + + const title = new TextDisplayBuilder().setContent( + `**${supportbotai.General?.Name || "SupportBot AI"}**`, + ); + + const body = new TextDisplayBuilder().setContent(replyContent); + + container.addTextDisplayComponents(title); + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + container.addTextDisplayComponents(body); + + if (suggestTicket) { + container.addSeparatorComponents(new SeparatorBuilder().setDivider(true)); + container.addTextDisplayComponents( + new TextDisplayBuilder().setContent(getSuggestTicketText()), + ); + } + + return message.reply({ + flags: MessageFlags.IsComponentsV2, + components: [container], + }); + } + + const embed = new Discord.EmbedBuilder() + .setAuthor({ + name: supportbotai.General.Name, + iconURL: client.user.displayAvatarURL(), + }) + .setDescription(`${replyContent}${ticketHint}`) + .setColor(supportbotai.Embed.Color) + .setFooter({ + text: supportbotai.Embed.Footer, + iconURL: message.author.displayAvatarURL(), + }) + .setTimestamp(); + + return message.reply({ embeds: [embed] }); +} + +function rememberUserFacts(message) { + if (!message?.guild || !message?.author || !message?.content) return; + + const content = message.content.trim(); + + const nameMatch = content.match(/^my name is\s+(.+)$/i); + if (nameMatch && typeof AIDatabase.saveUserFact === "function") { + AIDatabase.saveUserFact({ + guildId: message.guild.id, + userId: message.author.id, + factType: "profile", + factKey: "name", + factValue: nameMatch[1].trim(), + }); + } + + const nicknameMatch = content.match(/^i(?:'| a)?m\s+(.+)$/i); + if ( + nicknameMatch && + nicknameMatch[1] && + nicknameMatch[1].length <= 32 && + typeof AIDatabase.saveUserFact === "function" + ) { + AIDatabase.saveUserFact({ + guildId: message.guild.id, + userId: message.author.id, + factType: "profile", + factKey: "display_name", + factValue: nicknameMatch[1].trim(), + }); + } + + const usesMatch = content.match(/^i use\s+(.+)$/i); + if (usesMatch && typeof AIDatabase.saveUserFact === "function") { + AIDatabase.saveUserFact({ + guildId: message.guild.id, + userId: message.author.id, + factType: "preference", + factKey: "uses", + factValue: usesMatch[1].trim(), + }); + } } -// Event handler for message creation module.exports = new Event("messageCreate", async (client, message) => { - if (message.author.bot || !message.guild) return; - if (!CHANNELS.includes(message.channelId) && !message.mentions.users.has(client.user.id)) return; + if (message.author.bot || !message.guild) return; -if (supportbotai.Enabled) { + let shouldRespond = + CHANNELS.includes(message.channelId) || + message.mentions.users.has(client.user.id); - const sendTypingInterval = setInterval(() => { - message.channel.sendTyping(); - }, 5000); + if (!shouldRespond && message.channel.isThread()) { + let ticket = null; - let conversation = [{ + if (typeof db.getTicketByAIChannel === "function") { + ticket = db.getTicketByAIChannel(message.channel.id); + } + + if (!ticket && typeof db.getTicket === "function") { + ticket = db.getTicket(message.channel.id); + } + + if ( + !ticket && + message.channel.parentId && + typeof db.getTicket === "function" + ) { + ticket = db.getTicket(message.channel.parentId); + } + + if ( + ticket && + ticket.aiModeEnabled && + ticket.aiChannelId === message.channel.id + ) { + shouldRespond = true; + } + } + + if (!shouldRespond) return; + + if (!supportbotai.Enabled) { + const embed = new Discord.EmbedBuilder() + .setAuthor({ + name: supportbotai.General.Name, + iconURL: client.user.displayAvatarURL(), + }) + .setDescription( + supportbotai.Messages?.AIDisabled || "AI is currently disabled.", + ) + .setColor(supportbot.Embed.Colours.Error) + .setFooter({ + text: supportbotai.Embed.Footer, + iconURL: message.author.displayAvatarURL(), + }); + + return message.reply({ embeds: [embed] }); + } + + const typingInterval = setInterval(() => { + message.channel.sendTyping().catch(() => {}); + }, 5000); + + try { + const memoryConfig = supportbotai.Memory || {}; + const memoryEnabled = memoryConfig.Enabled !== false; + + if (memoryEnabled) { + AIDatabase.addMessage({ + guildId: message.guild.id, + channelId: message.channel.id, + userId: message.author.id, + role: "user", + content: message.content, + }); + + rememberUserFacts(message); + } + + const ticket = getTicketContextForMessage(message); + + const conversation = buildConversationFromMemory( + message.channel.id, + message, + ); + + if (ticket) { + conversation.push({ role: "system", - content: "SupportBot is your friendly neighbourhood support bot." - }]; + content: buildTicketContext(ticket, message), + }); + } - let prevMessages = await message.channel.messages.fetch({ limit: 10 }); - prevMessages = prevMessages.reverse().filter(msg => !msg.author.bot || msg.author.id === client.user.id); + const provider = getProvider(); - prevMessages.forEach((msg) => { - const username = msg.author.username.replace(/\s+/g, '_').replace(/[^\w\s]/gi, ''); - - conversation.push({ - role: msg.author.id === client.user.id ? "assistant" : "user", - name: username, - content: msg.content, - }); + const response = await provider.chat({ + messages: conversation, + temperature: supportbotai.General.Temperature || 0.4, + maxTokens: supportbotai.General.Tokens || 800, }); - try { - const response = await openai.chat.completions.create({ - model: supportbotai.General.Model, - messages: conversation, - max_tokens: supportbotai.General.Tokens, - }); + clearInterval(typingInterval); - clearInterval(sendTypingInterval); + if (!response?.content) { + const embed = new Discord.EmbedBuilder() + .setDescription( + supportbotai.Messages?.ErrorResponse || + "I couldn't generate a response.", + ) + .setColor(supportbotai.Embed.Color); - if (response.choices && response.choices.length > 0) { - const replyContent = response.choices[0].message.content; - - const SupportBotAI = new Discord.EmbedBuilder() - .setAuthor({ name: supportbotai.General.Name, iconURL: client.user.displayAvatarURL() }) - .setDescription(replyContent) - .setColor(supportbotai.Embed.Color) - .setFooter({ text: supportbotai.Embed.Footer, iconURL: message.author.displayAvatarURL() }) - .setTimestamp(); - - if (replyContent.length > 2000) { - const pasteLink = await createPaste(replyContent); - - const SupportBotAIPastebin = new Discord.EmbedBuilder() - .setAuthor({ name: supportbotai.General.Name, iconURL: client.user.displayAvatarURL() }) - .setDescription(supportbotai.Messages.MessageTooLong.replace("{pastebin}", pasteLink)) - .setColor(supportbotai.Embed.Color) - .setFooter({ text: supportbotai.Embed.Footer, iconURL: message.author.displayAvatarURL() }) - .setTimestamp(); - - message.reply({ embeds: [SupportBotAIPastebin] }); - } else { - message.reply({ embeds: [SupportBotAI] }); - } - } else { - const SupportBotAIErrorResponse = new Discord.EmbedBuilder() - .setAuthor({ name: supportbotai.General.Name, iconURL: client.user.displayAvatarURL() }) - .setDescription(supportbotai.Messages.ErrorResponse) - .setColor(supportbotai.Embed.Color) - .setFooter({ text: supportbotai.Embed.Footer, iconURL: message.author.displayAvatarURL() }) - .setTimestamp(); - - message.reply({ embeds: [SupportBotAIErrorResponse], ephemeral: true }); - } - } catch (error) { - clearInterval(sendTypingInterval); - - const SupportBotAIErrorResponse = new Discord.EmbedBuilder() - .setAuthor({ name: supportbotai.General.Name, iconURL: client.user.displayAvatarURL() }) - .setDescription(supportbotai.Messages.OpenAIError) - .setColor(supportbot.Embed.Colours.Error) - .setFooter({ text: supportbotai.Embed.Footer, iconURL: message.author.displayAvatarURL() }) - .setTimestamp(); - - console.error('OpenAI Error:', error); - message.reply({ embeds: [SupportBotAIErrorResponse], ephemeral: true }); + return message.reply({ embeds: [embed] }); } -} + const replyContent = response.content; -if (supportbotai.Enabled === false) { - const AIDisabled = new Discord.EmbedBuilder() - .setAuthor({ name: supportbotai.General.Name, iconURL: client.user.displayAvatarURL() }) - .setDescription(supportbotai.Messages.AIDisabled) - .setColor(supportbot.Embed.Colours.Error) - .setFooter({ text: supportbotai.Embed.Footer, iconURL: message.author.displayAvatarURL() }) - .setTimestamp(); + if (memoryEnabled) { + AIDatabase.addMessage({ + guildId: message.guild.id, + channelId: message.channel.id, + userId: client.user.id, + role: "assistant", + content: replyContent, + }); + } - message.reply({ embeds: [AIDisabled], ephemeral: true }) - return; -} + if (replyContent.length > 4000) { + const pasteLink = await createPaste(replyContent); -}); \ No newline at end of file + if (pasteLink) { + const embed = new Discord.EmbedBuilder() + .setDescription( + ( + supportbotai.Messages?.MessageTooLong || + "My response was too long, so I uploaded it here: {pastebin}" + ).replace("{pastebin}", pasteLink), + ) + .setColor(supportbotai.Embed.Color); + + return message.reply({ embeds: [embed] }); + } + + return message.reply({ + content: replyContent.slice(0, 1900), + }); + } + + const suggestTicket = shouldSuggestTicket(message, ticket); + + const sentMessage = await sendAIResponse({ + client, + message, + replyContent, + suggestTicket: false, + }); + + if ( + shouldSuggestTicket(message, ticket) && + supportbotai.Features?.SuggestTickets === true + ) { + const hintMessage = await message.channel.send( + supportbotai.Messages?.SuggestTicketText || + "Need more help? React with šŸŽ« to open a ticket.", + ); + + await hintMessage + .react(supportbotai.Messages?.SuggestTicketReaction || "šŸŽ«") + .catch(() => {}); + + setTimeout( + () => { + hintMessage.delete().catch(() => {}); + }, + 5 * 60 * 1000, + ); + } + } catch (error) { + clearInterval(typingInterval); + + console.error("AI Error:", error); + + const embed = new Discord.EmbedBuilder() + .setDescription( + supportbotai.Messages?.AIError || + supportbotai.Messages?.ErrorResponse || + "There was an error contacting the AI provider.", + ) + .setColor(supportbot.Embed.Colours.Error); + + message.reply({ embeds: [embed] }).catch(() => {}); + } +}); diff --git a/Events/messageReactionAdd.js b/Events/messageReactionAdd.js new file mode 100644 index 0000000..25e6508 --- /dev/null +++ b/Events/messageReactionAdd.js @@ -0,0 +1,75 @@ +const fs = require("fs"); +const yaml = require("js-yaml"); + +const supportbot = yaml.load( + fs.readFileSync("./Configs/supportbot.yml", "utf8"), +); +const supportbotai = yaml.load( + fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"), +); +const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8")); + +const Event = require("../Structures/Event.js"); + +module.exports = new Event( + "messageReactionAdd", + async (client, reaction, user) => { + try { + if (user.bot) return; + + if (reaction.partial) { + await reaction.fetch().catch(() => null); + } + + const message = reaction.message; + if (!message || !message.guild) return; + + const reactionEmoji = reaction.emoji?.name || reaction.emoji?.id || null; + const expectedReaction = + supportbotai.Messages?.SuggestTicketReaction || "šŸŽ«"; + + if (reactionEmoji !== expectedReaction) return; + if (supportbotai.Features?.SuggestTickets !== true) return; + if (message.author?.id !== client.user.id) return; + + const expectedText = + supportbotai.Messages?.SuggestTicketText || + "Need more help? React with šŸŽ« to open a ticket."; + + if ((message.content || "").trim() !== expectedText.trim()) return; + + await message.delete().catch(() => {}); + + const cmd = client.commands.get(cmdconfig.OpenTicket.Command); + if (!cmd) { + console.error("Open ticket command not found."); + return; + } + + const member = await message.guild.members + .fetch(user.id) + .catch(() => null); + + if (!member) return; + + const fakeInteraction = { + user, + member, + guild: message.guild, + channel: message.channel, + client, + replied: true, + deferred: false, + reason: `Opened from AI suggestion in #${message.channel.name}`, + options: { + getString: () => null, + }, + reply: async () => null, + }; + + await cmd.run(fakeInteraction); + } catch (error) { + console.error("Error handling AI ticket reaction:", error); + } + }, +); diff --git a/Structures/AIDatabase.js b/Structures/AIDatabase.js new file mode 100644 index 0000000..077c861 --- /dev/null +++ b/Structures/AIDatabase.js @@ -0,0 +1,289 @@ +const Database = require("better-sqlite3"); +const path = require("path"); + +const db = new Database(path.join(__dirname, "../Data/ai-memory.db")); + +db.pragma("journal_mode = WAL"); + +db.prepare( + ` + CREATE TABLE IF NOT EXISTS ai_conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT, + channel_id TEXT, + user_id TEXT, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL + ) +`, +).run(); + +db.prepare( + ` + CREATE TABLE IF NOT EXISTS ai_summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT, + channel_id TEXT, + summary TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) +`, +).run(); + +db.prepare( + ` + CREATE TABLE IF NOT EXISTS ai_facts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT, + channel_id TEXT, + user_id TEXT, + fact_type TEXT NOT NULL, + fact_key TEXT NOT NULL, + fact_value TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) +`, +).run(); + +db.prepare( + ` + CREATE TABLE IF NOT EXISTS ai_knowledge ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + source_type TEXT NOT NULL, + source_name TEXT NOT NULL, + title TEXT NOT NULL, + content TEXT NOT NULL, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) +`, +).run(); + +db.prepare( + ` + CREATE TABLE IF NOT EXISTS ai_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + guild_id TEXT, + channel_id TEXT, + user_id TEXT, + model TEXT, + mode TEXT, + last_message_at INTEGER, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) +`, +).run(); + +try { + db.prepare(`ALTER TABLE ai_facts ADD COLUMN user_id TEXT`).run(); +} catch (e) { + if (!String(e).includes("duplicate column name")) throw e; +} + +module.exports = { + + saveUserFact({ guildId, userId, factType, factKey, factValue }) { + const existing = db + .prepare( + ` + SELECT id FROM ai_facts + WHERE guild_id IS ? AND user_id IS ? AND fact_type = ? AND fact_key = ? + LIMIT 1 + `, + ) + .get(guildId || null, userId || null, factType, factKey); + + if (existing) { + db.prepare( + ` + UPDATE ai_facts + SET fact_value = ?, updated_at = ? + WHERE id = ? + `, + ).run(factValue, Date.now(), existing.id); + return; + } + + db.prepare( + ` + INSERT INTO ai_facts ( + guild_id, channel_id, user_id, fact_type, fact_key, fact_value, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?) + `, + ).run( + guildId || null, + null, + userId || null, + factType, + factKey, + factValue, + Date.now(), + Date.now(), + ); + }, + + getUserFacts(guildId, userId) { + return db + .prepare( + ` + SELECT * FROM ai_facts + WHERE guild_id IS ? AND user_id IS ? + ORDER BY updated_at DESC + `, + ) + .all(guildId || null, userId || null); + }, + + addMessage({ guildId, channelId, userId, role, content }) { + db.prepare( + ` + INSERT INTO ai_conversations ( + guild_id, channel_id, user_id, role, content, created_at + ) VALUES (?, ?, ?, ?, ?, ?) + `, + ).run( + guildId || null, + channelId || null, + userId || null, + role, + content, + Date.now(), + ); + }, + + getRecentMessages(channelId, limit = 15) { + return db + .prepare( + ` + SELECT * FROM ai_conversations + WHERE channel_id = ? + ORDER BY created_at DESC + LIMIT ? + `, + ) + .all(channelId, limit) + .reverse(); + }, + + saveSummary({ guildId, channelId, summary }) { + const existing = db + .prepare( + ` + SELECT id FROM ai_summaries + WHERE guild_id IS ? AND channel_id IS ? + ORDER BY updated_at DESC + LIMIT 1 + `, + ) + .get(guildId || null, channelId || null); + + if (existing) { + db.prepare( + ` + UPDATE ai_summaries + SET summary = ?, updated_at = ? + WHERE id = ? + `, + ).run(summary, Date.now(), existing.id); + return; + } + + db.prepare( + ` + INSERT INTO ai_summaries ( + guild_id, channel_id, summary, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?) + `, + ).run(guildId || null, channelId || null, summary, Date.now(), Date.now()); + }, + + getLatestSummary(channelId) { + return db + .prepare( + ` + SELECT * FROM ai_summaries + WHERE channel_id = ? + ORDER BY updated_at DESC + LIMIT 1 + `, + ) + .get(channelId); + }, + + saveFact({ guildId, channelId, factType, factKey, factValue }) { + const existing = db + .prepare( + ` + SELECT id FROM ai_facts + WHERE guild_id IS ? AND channel_id IS ? AND fact_type = ? AND fact_key = ? + LIMIT 1 + `, + ) + .get(guildId || null, channelId || null, factType, factKey); + + if (existing) { + db.prepare( + ` + UPDATE ai_facts + SET fact_value = ?, updated_at = ? + WHERE id = ? + `, + ).run(factValue, Date.now(), existing.id); + return; + } + + db.prepare( + ` + INSERT INTO ai_facts ( + guild_id, channel_id, fact_type, fact_key, fact_value, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?) + `, + ).run( + guildId || null, + channelId || null, + factType, + factKey, + factValue, + Date.now(), + Date.now(), + ); + }, + + getFacts(channelId) { + return db + .prepare( + ` + SELECT * FROM ai_facts + WHERE channel_id = ? + ORDER BY updated_at DESC + `, + ) + .all(channelId); + }, + + addKnowledge({ sourceType, sourceName, title, content }) { + db.prepare( + ` + INSERT INTO ai_knowledge ( + source_type, source_name, title, content, created_at, updated_at + ) VALUES (?, ?, ?, ?, ?, ?) + `, + ).run(sourceType, sourceName, title, content, Date.now(), Date.now()); + }, + + getKnowledge(limit = 25) { + return db + .prepare( + ` + SELECT * FROM ai_knowledge + ORDER BY updated_at DESC + LIMIT ? + `, + ) + .all(limit); + }, +}; diff --git a/Structures/Client.js b/Structures/Client.js index a5e7b81..4d0a6b3 100644 --- a/Structures/Client.js +++ b/Structures/Client.js @@ -49,9 +49,10 @@ class Client extends Discord.Client { GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers, GatewayIntentBits.GuildMessages, + GatewayIntentBits.GuildMessageReactions, GatewayIntentBits.MessageContent, ], - partials: [Partials.Message, Partials.Channel], + partials: [Partials.Message, Partials.Channel, Partials.Reaction], }); this.commands = new Discord.Collection(); diff --git a/Structures/Database.js b/Structures/Database.js index 657d228..3ec4803 100644 --- a/Structures/Database.js +++ b/Structures/Database.js @@ -52,6 +52,14 @@ const safeAlter = (sql) => { safeAlter(`ALTER TABLE tickets ADD COLUMN voiceChannelId TEXT`); safeAlter(`ALTER TABLE tickets ADD COLUMN department TEXT DEFAULT 'general'`); safeAlter(`ALTER TABLE tickets ADD COLUMN priority TEXT DEFAULT 'medium'`); +safeAlter(`ALTER TABLE tickets ADD COLUMN aiModeEnabled INTEGER DEFAULT 0`); +safeAlter(`ALTER TABLE tickets ADD COLUMN aiChannelId TEXT`); +safeAlter(`ALTER TABLE tickets ADD COLUMN aiType TEXT`); +safeAlter(`ALTER TABLE tickets ADD COLUMN aiEnabledBy TEXT`); +safeAlter(`ALTER TABLE tickets ADD COLUMN aiEnabledAt INTEGER`); +safeAlter(`ALTER TABLE tickets ADD COLUMN questionAnswers TEXT`); +safeAlter(`ALTER TABLE tickets ADD COLUMN ticketName TEXT`); +safeAlter(`ALTER TABLE tickets ADD COLUMN ticketNumber TEXT`); db.prepare( ` @@ -84,18 +92,46 @@ db.prepare( ).run(); function ensureProfile(userId) { - const existing = db.prepare(` + const existing = db + .prepare( + ` SELECT user_id FROM profiles WHERE user_id = ? - `).get(userId); + `, + ) + .get(userId); if (!existing) { - db.prepare(` + db.prepare( + ` INSERT INTO profiles (user_id, bio, timezone, clockedIn) VALUES (?, '', '', 0) - `).run(userId); + `, + ).run(userId); } } +function normaliseTicketRow(row) { + if (!row) return null; + + let questionAnswers = []; + + if (row.questionAnswers) { + try { + questionAnswers = JSON.parse(row.questionAnswers); + } catch { + questionAnswers = []; + } + } + + return { + ...row, + aiModeEnabled: Boolean(row.aiModeEnabled), + clockedIn: + row.clockedIn !== undefined ? Boolean(row.clockedIn) : row.clockedIn, + questionAnswers, + }; +} + module.exports = { addTicket(ticket) { const now = Date.now(); @@ -110,7 +146,15 @@ module.exports = { created_at, updated_at, department, - priority + priority, + aiModeEnabled, + aiChannelId, + aiType, + aiEnabledBy, + aiEnabledAt, + questionAnswers, + ticketName, + ticketNumber ) VALUES ( @ticket_id, @@ -120,7 +164,15 @@ module.exports = { @created_at, @updated_at, @department, - @priority + @priority, + @aiModeEnabled, + @aiChannelId, + @aiType, + @aiEnabledBy, + @aiEnabledAt, + @questionAnswers, + @ticketName, + @ticketNumber ) ON CONFLICT(ticket_id) DO NOTHING `, @@ -133,6 +185,28 @@ module.exports = { updated_at: now, department: ticket.department || "general", priority: ticket.priority || "medium", + aiModeEnabled: ticket.aiModeEnabled ? 1 : 0, + aiChannelId: ticket.aiChannelId || null, + aiType: ticket.aiType || null, + aiEnabledBy: ticket.aiEnabledBy || null, + aiEnabledAt: ticket.aiEnabledAt || null, + questionAnswers: JSON.stringify(ticket.questionAnswers || []), + ticketName: ticket.name || null, + ticketNumber: ticket.number || null, + }); + }, + + updateTicketQuestionAnswers(ticketId, questionAnswers = []) { + db.prepare( + ` + UPDATE tickets + SET questionAnswers=@questionAnswers, updated_at=@updatedAt + WHERE ticket_id=@ticketId + `, + ).run({ + ticketId, + questionAnswers: JSON.stringify(questionAnswers), + updatedAt: Date.now(), }); }, @@ -192,18 +266,103 @@ module.exports = { }); }, + setTicketAIState(ticketId, aiState = {}) { + db.prepare( + ` + UPDATE tickets + SET + aiModeEnabled=@aiModeEnabled, + aiChannelId=@aiChannelId, + aiType=@aiType, + aiEnabledBy=@aiEnabledBy, + aiEnabledAt=@aiEnabledAt, + updated_at=@updatedAt + WHERE ticket_id=@ticketId + `, + ).run({ + ticketId, + aiModeEnabled: aiState.enabled ? 1 : 0, + aiChannelId: aiState.aiChannelId || null, + aiType: aiState.aiType || null, + aiEnabledBy: aiState.enabledBy || null, + aiEnabledAt: aiState.enabledAt + ? new Date(aiState.enabledAt).getTime() + : aiState.enabled + ? Date.now() + : null, + updatedAt: Date.now(), + }); + }, + + enableTicketAI( + ticketId, + aiChannelId = null, + aiType = null, + enabledBy = null, + ) { + db.prepare( + ` + UPDATE tickets + SET + aiModeEnabled=1, + aiChannelId=@aiChannelId, + aiType=@aiType, + aiEnabledBy=@aiEnabledBy, + aiEnabledAt=@aiEnabledAt, + updated_at=@updatedAt + WHERE ticket_id=@ticketId + `, + ).run({ + ticketId, + aiChannelId, + aiType, + aiEnabledBy, + aiEnabledAt: Date.now(), + updatedAt: Date.now(), + }); + }, + + disableTicketAI(ticketId) { + db.prepare( + ` + UPDATE tickets + SET + aiModeEnabled=0, + updated_at=@updatedAt + WHERE ticket_id=@ticketId + `, + ).run({ + ticketId, + updatedAt: Date.now(), + }); + }, + getTicket(ticketId) { - return db + const row = db .prepare( ` SELECT * FROM tickets WHERE ticket_id=@ticketId `, ) .get({ ticketId }); + + return normaliseTicketRow(row); + }, + + getTicketByAIChannel(aiChannelId) { + const row = db + .prepare( + ` + SELECT * FROM tickets WHERE aiChannelId=@aiChannelId LIMIT 1 + `, + ) + .get({ aiChannelId }); + + return normaliseTicketRow(row); }, getAllTickets() { - return db.prepare(`SELECT * FROM tickets`).all(); + return db.prepare(`SELECT * FROM tickets`).all().map(normaliseTicketRow); }, saveTicketPanel(messageId, channelId) { @@ -267,9 +426,13 @@ module.exports = { getProfile(userId) { ensureProfile(userId); - const row = db.prepare(` + const row = db + .prepare( + ` SELECT * FROM profiles WHERE user_id = ? - `).get(userId); + `, + ) + .get(userId); if (!row) { return { @@ -289,11 +452,13 @@ module.exports = { setClockedIn(userId, clockedIn) { ensureProfile(userId); - db.prepare(` + db.prepare( + ` UPDATE profiles SET clockedIn = ? WHERE user_id = ? - `).run(clockedIn ? 1 : 0, userId); + `, + ).run(clockedIn ? 1 : 0, userId); }, saveSettings(obj) {