26.0-build-2

This commit is contained in:
1tzEmerald committed 2026-03-11 18:58:15 +00:00
1 parent 9448c4e3d6
commit 15b780ab62
12 files changed
+1694 -319

No files matched your search

+311 -132
View File
@@ -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);
+16 -11
View File
@@ -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
# ----------------------------
+46 -20
View File
@@ -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<hexcode>
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`"
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 : "🎫"
+13 -8
View File
@@ -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:
+72
View File
@@ -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,
};
+68
View File
@@ -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,
};
+121
View File
@@ -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);
+504 -135
View File
@@ -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);
});
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(() => {});
}
});
+75
View File
@@ -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);
}
},
);
+289
View File
@@ -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);
},
};
+2 -1
View File
@@ -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();
+177 -12
View File
@@ -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) {