Compare commits
35
Commits
v26.1-pre1
...
release
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b941909983 | ||
|
|
01f9ebe387 | ||
|
|
a746c468e5 | ||
|
|
2017c33756 | ||
|
|
4eef1867e3 | ||
|
|
a7598d26aa | ||
|
|
762fcf45c3 | ||
|
|
ea2a8c97ae | ||
|
|
004b638dbf | ||
|
|
69371ae619 | ||
|
|
b145d5fa01 | ||
|
|
8c9fcb32e0 | ||
|
|
17d45de378 | ||
|
|
b96c60912c | ||
|
|
e97e281bda | ||
|
|
00cebb7bb5 | ||
|
|
014f263bf1 | ||
|
|
942d71040b | ||
|
|
92d21e7fc0 | ||
|
|
07e4705a6a | ||
|
|
6320296f0f | ||
|
|
f4b14e6c01 | ||
|
|
7e22831d08 | ||
|
|
9332fc9254 | ||
|
|
018c949104 | ||
|
|
b55976508e | ||
|
|
264dcb3053 | ||
|
|
22c33c0f7a | ||
|
|
57d420a9f4 | ||
|
|
f8cc08ba3e | ||
|
|
60d5c4cb1f | ||
|
|
0288e2f4de | ||
|
|
33d5a79759 | ||
|
|
30b89a3cf9 | ||
|
|
427dc2d040 |
No files matched your search
@@ -0,0 +1,2 @@
|
|||||||
|
Configs/supportbot.yml filter=supportbot-token
|
||||||
|
Configs/api.yml filter=api-secrets
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
node_modules
|
||||||
|
|
||||||
|
# Local database, runtime data, and transcripts
|
||||||
|
Data/*.db
|
||||||
|
Data/*.db-shm
|
||||||
|
Data/*.db-wal
|
||||||
|
Data/Transcripts/*
|
||||||
|
Data/dashboard-groups.json
|
||||||
|
Data/dashboard-users.json
|
||||||
|
Data/transcript-settings.json
|
||||||
|
|
||||||
|
# Local log files
|
||||||
|
Logs/Output/*
|
||||||
|
Logs/Errors/*
|
||||||
|
Logs/Warns/*
|
||||||
|
|
||||||
|
# Dashboard UI source at repo root only (do not ignore public/dashboard/)
|
||||||
|
/dashboard/
|
||||||
|
|
||||||
|
# Local secrets — use *.example as templates
|
||||||
|
Configs/api.yml
|
||||||
+1872
-97
File diff suppressed because it is too large.
Load diff
+1
-1
@@ -2,7 +2,7 @@ const Discord = require("discord.js");
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const { Command } = require('../Structures/Addon.js'); // Adjust the path as needed
|
const { Command } = require('../Structures/Addon.js'); // Adjust the path as needed
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||||
|
|
||||||
module.exports = new Command({
|
module.exports = new Command({
|
||||||
name: "addon",
|
name: "addon",
|
||||||
|
|||||||
@@ -5,9 +5,9 @@ const fs = require("fs");
|
|||||||
const { ApplicationCommandOptionType, ApplicationCommandType, EmbedBuilder, MessageFlags } = require("discord.js");
|
const { ApplicationCommandOptionType, ApplicationCommandType, EmbedBuilder, MessageFlags } = require("discord.js");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
const supportbotai = yaml.load(fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"));
|
const supportbotai = require("../../Structures/ConfigStore").supportbotAi;
|
||||||
|
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
const AIDatabase = require("../../Structures/AIDatabase.js");
|
const AIDatabase = require("../../Structures/AIDatabase.js");
|
||||||
|
|||||||
@@ -2,8 +2,8 @@ const fs = require("fs");
|
|||||||
const Discord = require("discord.js");
|
const Discord = require("discord.js");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
|
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
|
|
||||||
@@ -54,7 +54,7 @@ module.exports = new Command({
|
|||||||
iconURL: interaction.user.displayAvatarURL(),
|
iconURL: interaction.user.displayAvatarURL(),
|
||||||
});
|
});
|
||||||
|
|
||||||
interaction.reply({
|
await interaction.reply({
|
||||||
flags: Discord.MessageFlags.Ephemeral,
|
flags: Discord.MessageFlags.Ephemeral,
|
||||||
embeds: [HelpEmbed1],
|
embeds: [HelpEmbed1],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,22 +1,11 @@
|
|||||||
// SupportBot | Emerald Services
|
// SupportBot | Emerald Services
|
||||||
// Info Command
|
// Info Command
|
||||||
|
|
||||||
const fs = require("fs");
|
|
||||||
|
|
||||||
const Discord = require("discord.js");
|
const Discord = require("discord.js");
|
||||||
const yaml = require("js-yaml");
|
|
||||||
|
|
||||||
const supportbot = yaml.load(
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
);
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
const cmdconfig = yaml.load(
|
|
||||||
fs.readFileSync("./Configs/commands.yml", "utf8")
|
|
||||||
);
|
|
||||||
|
|
||||||
const msgconfig = yaml.load(
|
|
||||||
fs.readFileSync("./Configs/messages.yml", "utf8")
|
|
||||||
);
|
|
||||||
|
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
|
|
||||||
|
|||||||
@@ -5,10 +5,8 @@ const fs = require("fs");
|
|||||||
|
|
||||||
const Discord = require("discord.js");
|
const Discord = require("discord.js");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const supportbot = yaml.load(
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
);
|
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
|
||||||
|
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
|
|
||||||
@@ -19,15 +17,13 @@ module.exports = new Command({
|
|||||||
permissions: cmdconfig.Ping.Permission,
|
permissions: cmdconfig.Ping.Permission,
|
||||||
|
|
||||||
async run(interaction) {
|
async run(interaction) {
|
||||||
let disableCommand = true;
|
|
||||||
|
|
||||||
const PingEmbed = new Discord.EmbedBuilder()
|
const PingEmbed = new Discord.EmbedBuilder()
|
||||||
.setDescription(
|
.setDescription(
|
||||||
`:ping_pong: **Ping:** \`${interaction.client.ws.ping} ms\``
|
`:ping_pong: **Ping:** \`${interaction.client.ws.ping} ms\``,
|
||||||
)
|
)
|
||||||
.setColor(supportbot.Embed.Colours.General);
|
.setColor(supportbot.Embed.Colours.General);
|
||||||
|
|
||||||
interaction.reply({
|
await interaction.reply({
|
||||||
embeds: [PingEmbed],
|
embeds: [PingEmbed],
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|||||||
+40
-64
@@ -20,9 +20,9 @@ const yaml = require("js-yaml");
|
|||||||
const db = require("../../Structures/Database.js");
|
const db = require("../../Structures/Database.js");
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
const clockedInUsers = new Set();
|
const clockedInUsers = new Set();
|
||||||
|
|
||||||
@@ -177,6 +177,37 @@ function buildTicketStatsContainer(viewingUserId, ticketsClaimed, ticketsOpen, a
|
|||||||
return container;
|
return container;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getStaffTicketStats(viewingUserId) {
|
||||||
|
const allTickets = typeof db.getAllTickets === "function" ? db.getAllTickets() : [];
|
||||||
|
const ticketsClaimed = allTickets.filter(
|
||||||
|
(t) => t.claimedBy === viewingUserId || t.claimed_by === viewingUserId,
|
||||||
|
);
|
||||||
|
const ticketsOpen = ticketsClaimed.filter(
|
||||||
|
(t) => (t.status || "").toLowerCase() === "open",
|
||||||
|
);
|
||||||
|
|
||||||
|
let totalResponseTime = 0;
|
||||||
|
let responseCount = 0;
|
||||||
|
|
||||||
|
ticketsClaimed.forEach((t) => {
|
||||||
|
const claimedAt = t.claimedAt || t.claimed_at;
|
||||||
|
const createdAt = t.createdAt || t.created_at;
|
||||||
|
if (claimedAt && createdAt) {
|
||||||
|
const diff = new Date(claimedAt).getTime() - new Date(createdAt).getTime();
|
||||||
|
if (diff > 0) {
|
||||||
|
totalResponseTime += diff;
|
||||||
|
responseCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const avgMinutes = responseCount
|
||||||
|
? Math.round(totalResponseTime / responseCount / 60000)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
return { ticketsClaimed, ticketsOpen, avgMinutes };
|
||||||
|
}
|
||||||
|
|
||||||
function buildOpenTicketsContainer(ticketsOpen) {
|
function buildOpenTicketsContainer(ticketsOpen) {
|
||||||
const container = new ContainerBuilder();
|
const container = new ContainerBuilder();
|
||||||
|
|
||||||
@@ -192,7 +223,9 @@ function buildOpenTicketsContainer(ticketsOpen) {
|
|||||||
|
|
||||||
container.addTextDisplayComponents(
|
container.addTextDisplayComponents(
|
||||||
new TextDisplayBuilder().setContent(
|
new TextDisplayBuilder().setContent(
|
||||||
ticketsOpen.length ? ticketsOpen.map((t) => `<#${t.id}>`).join("\n") : "No active tickets.",
|
ticketsOpen.length
|
||||||
|
? ticketsOpen.map((t) => `<#${t.ticket_id || t.id}>`).join("\n")
|
||||||
|
: "No active tickets.",
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -352,31 +385,7 @@ module.exports = new Command({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (i.customId === "viewTicketStats") {
|
if (i.customId === "viewTicketStats") {
|
||||||
let TicketData = { tickets: [] };
|
const { ticketsClaimed, ticketsOpen, avgMinutes } = getStaffTicketStats(viewingUserId);
|
||||||
|
|
||||||
try {
|
|
||||||
TicketData = JSON.parse(fs.readFileSync("./Data/TicketData.json", "utf8"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error reading TicketData.json:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticketsClaimed = TicketData.tickets.filter((t) => t.claimedBy === viewingUserId);
|
|
||||||
const ticketsOpen = ticketsClaimed.filter((t) => t.open);
|
|
||||||
const totalTickets = ticketsClaimed.length;
|
|
||||||
|
|
||||||
let totalResponseTime = 0;
|
|
||||||
let responseCount = 0;
|
|
||||||
|
|
||||||
ticketsClaimed.forEach((t) => {
|
|
||||||
if (t.claimedAt && t.createdAt) {
|
|
||||||
totalResponseTime += new Date(t.claimedAt) - new Date(t.createdAt);
|
|
||||||
responseCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const avgMinutes = responseCount
|
|
||||||
? Math.round(totalResponseTime / responseCount / 60000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const statsContainer = buildTicketStatsContainer(
|
const statsContainer = buildTicketStatsContainer(
|
||||||
viewingUserId,
|
viewingUserId,
|
||||||
@@ -403,17 +412,7 @@ module.exports = new Command({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (i.customId === "showOpenTickets") {
|
if (i.customId === "showOpenTickets") {
|
||||||
let TicketData = { tickets: [] };
|
const { ticketsOpen } = getStaffTicketStats(viewingUserId);
|
||||||
|
|
||||||
try {
|
|
||||||
TicketData = JSON.parse(fs.readFileSync("./Data/TicketData.json", "utf8"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error reading TicketData.json:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticketsOpen = TicketData.tickets.filter(
|
|
||||||
(t) => t.claimedBy === viewingUserId && t.open,
|
|
||||||
);
|
|
||||||
|
|
||||||
const openContainer = buildOpenTicketsContainer(ticketsOpen);
|
const openContainer = buildOpenTicketsContainer(ticketsOpen);
|
||||||
|
|
||||||
@@ -431,30 +430,7 @@ module.exports = new Command({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (i.customId === "backToStats") {
|
if (i.customId === "backToStats") {
|
||||||
let TicketData = { tickets: [] };
|
const { ticketsClaimed, ticketsOpen, avgMinutes } = getStaffTicketStats(viewingUserId);
|
||||||
|
|
||||||
try {
|
|
||||||
TicketData = JSON.parse(fs.readFileSync("./Data/TicketData.json", "utf8"));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error reading TicketData.json:", error);
|
|
||||||
}
|
|
||||||
|
|
||||||
const ticketsClaimed = TicketData.tickets.filter((t) => t.claimedBy === viewingUserId);
|
|
||||||
const ticketsOpen = ticketsClaimed.filter((t) => t.open);
|
|
||||||
|
|
||||||
let totalResponseTime = 0;
|
|
||||||
let responseCount = 0;
|
|
||||||
|
|
||||||
ticketsClaimed.forEach((t) => {
|
|
||||||
if (t.claimedAt && t.createdAt) {
|
|
||||||
totalResponseTime += new Date(t.claimedAt) - new Date(t.createdAt);
|
|
||||||
responseCount++;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
const avgMinutes = responseCount
|
|
||||||
? Math.round(totalResponseTime / responseCount / 60000)
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
const statsContainer = buildTicketStatsContainer(
|
const statsContainer = buildTicketStatsContainer(
|
||||||
viewingUserId,
|
viewingUserId,
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ const yaml = require("js-yaml");
|
|||||||
|
|
||||||
const { translate } = require("@vitalets/google-translate-api");
|
const { translate } = require("@vitalets/google-translate-api");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
|
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ const Discord = require("discord.js");
|
|||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
const DB = require("../../Structures/Database.js");
|
const DB = require("../../Structures/Database.js");
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ const Discord = require("discord.js");
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
const SuggestionDB = require("../../Structures/Database.js");
|
const SuggestionDB = require("../../Structures/Database.js");
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
|
|
||||||
|
|||||||
@@ -8,9 +8,9 @@ const db = require("../../Structures/Database.js");
|
|||||||
const TicketManager = require("../../Structures/TicketManager.js");
|
const TicketManager = require("../../Structures/TicketManager.js");
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
function getDepartmentConfig(departmentKey) {
|
function getDepartmentConfig(departmentKey) {
|
||||||
return supportbot.Ticket?.DepartmentSystem?.Departments?.[departmentKey] || null;
|
return supportbot.Ticket?.DepartmentSystem?.Departments?.[departmentKey] || null;
|
||||||
@@ -33,9 +33,10 @@ function buildContainer(title, lines = []) {
|
|||||||
|
|
||||||
container.addSeparatorComponents(new Discord.SeparatorBuilder().setDivider(true));
|
container.addSeparatorComponents(new Discord.SeparatorBuilder().setDivider(true));
|
||||||
|
|
||||||
if (lines.length > 0) {
|
const textContent = lines.filter(Boolean).join("\n").trim();
|
||||||
|
if (textContent.length > 0) {
|
||||||
container.addTextDisplayComponents(
|
container.addTextDisplayComponents(
|
||||||
new Discord.TextDisplayBuilder().setContent(lines.join("\n")),
|
new Discord.TextDisplayBuilder().setContent(textContent),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,9 +12,9 @@ const {
|
|||||||
TextInputStyle,
|
TextInputStyle,
|
||||||
} = require("discord.js");
|
} = require("discord.js");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
|
|
||||||
const TicketManager = require("../../Structures/TicketManager.js");
|
const TicketManager = require("../../Structures/TicketManager.js");
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
|
|||||||
@@ -19,35 +19,16 @@ const yaml = require("js-yaml");
|
|||||||
|
|
||||||
const db = require("../../Structures/Database.js");
|
const db = require("../../Structures/Database.js");
|
||||||
|
|
||||||
const panelconfig = yaml.load(
|
const panelconfig = require("../../Structures/ConfigStore").ticketPanel;
|
||||||
fs.readFileSync("./Configs/ticket-panel.yml", "utf8"),
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
);
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
const supportbot = yaml.load(
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
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"));
|
|
||||||
|
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
const TicketNumberID = require("../../Structures/TicketID.js");
|
const TicketNumberID = require("../../Structures/TicketID.js");
|
||||||
|
|
||||||
async function isBlacklisted(userId) {
|
async function isBlacklisted(userId) {
|
||||||
let blacklistData;
|
return typeof db.isUserBlacklisted === "function" ? db.isUserBlacklisted(userId) : false;
|
||||||
try {
|
|
||||||
blacklistData = JSON.parse(
|
|
||||||
fs.readFileSync("./Data/BlacklistedUsers.json", "utf8"),
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error reading BlacklistedUsers.json:", error);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!blacklistData || !Array.isArray(blacklistData.blacklistedUsers)) {
|
|
||||||
console.error("blacklistedUsers is not defined or is not an array");
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
return blacklistData.blacklistedUsers.includes(userId);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getClockedInUsers(guild) {
|
async function getClockedInUsers(guild) {
|
||||||
|
|||||||
@@ -19,9 +19,9 @@ const {
|
|||||||
} = require("discord.js");
|
} = require("discord.js");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
|
|
||||||
const panelconfig = yaml.load(fs.readFileSync("./Configs/ticket-panel.yml", "utf8"));
|
const panelconfig = require("../../Structures/ConfigStore").ticketPanel;
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
|
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
const db = require("../../Structures/Database.js");
|
const db = require("../../Structures/Database.js");
|
||||||
@@ -116,6 +116,7 @@ module.exports = new Command({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const departmentOptions = Object.entries(departments)
|
const departmentOptions = Object.entries(departments)
|
||||||
|
.filter(([_, dept]) => dept && dept.Enabled !== false)
|
||||||
.slice(0, 25)
|
.slice(0, 25)
|
||||||
.map(([key, dept]) => ({
|
.map(([key, dept]) => ({
|
||||||
label: dept.Name || key,
|
label: dept.Name || key,
|
||||||
|
|||||||
@@ -16,9 +16,9 @@ const Command = require('../../Structures/Command.js');
|
|||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const yaml = require('js-yaml');
|
const yaml = require('js-yaml');
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync('./Configs/supportbot.yml', 'utf8'));
|
const supportbot = require('../../Structures/ConfigStore').supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync('./Configs/commands.yml', 'utf8'));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
const msgconfig = yaml.load(fs.readFileSync('./Configs/messages.yml', 'utf8'));
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
function parseColor(color) {
|
function parseColor(color) {
|
||||||
if (!color) return 0x5865F2;
|
if (!color) return 0x5865F2;
|
||||||
|
|||||||
+26
-52
@@ -1,9 +1,10 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const { ApplicationCommandOptionType, ApplicationCommandType, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } = require("discord.js");
|
const { ApplicationCommandOptionType, ApplicationCommandType, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } = require("discord.js");
|
||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const db = require("../../Structures/Database.js");
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
|
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
const Command = require("../../Structures/Command.js");
|
const Command = require("../../Structures/Command.js");
|
||||||
|
|
||||||
@@ -18,28 +19,28 @@ function chunkArray(arr, size) {
|
|||||||
|
|
||||||
module.exports = new Command({
|
module.exports = new Command({
|
||||||
name: cmdconfig.Mod.Command,
|
name: cmdconfig.Mod.Command,
|
||||||
description: cmdconfig.Mod.Command,
|
description: cmdconfig.Mod.Description || "Moderation commands",
|
||||||
type: ApplicationCommandType.ChatInput,
|
type: ApplicationCommandType.ChatInput,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: cmdconfig.Mod.TicketBlacklist.Command,
|
name: cmdconfig.Mod.TicketBlacklist.Command,
|
||||||
description: cmdconfig.Mod.TicketBlacklist.Description,
|
description: cmdconfig.Mod.TicketBlacklist.Description || "Ticket Blacklist Commands",
|
||||||
type: ApplicationCommandOptionType.SubcommandGroup,
|
type: ApplicationCommandOptionType.SubcommandGroup,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: cmdconfig.Mod.TicketBlacklist.Add.Command,
|
name: cmdconfig.Mod.TicketBlacklist.Add.Command,
|
||||||
description: msgconfig.Mod.TicketBlacklist.Add.Description || "Add a user to the ticket blacklist",
|
description: msgconfig.Mod.TicketBlacklist.Add.Description || "Add a user to the blacklist",
|
||||||
type: ApplicationCommandOptionType.Subcommand,
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: 'user',
|
name: 'user',
|
||||||
description: msgconfig.Mod.TicketBlacklist.Add.UserDescription || "User to blacklist",
|
description: 'The user to blacklist',
|
||||||
type: ApplicationCommandOptionType.User,
|
type: ApplicationCommandOptionType.User,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'reason',
|
name: 'reason',
|
||||||
description: msgconfig.Mod.TicketBlacklist.Add.ReasonDescription || "Reason for blacklisting",
|
description: 'Reason for blacklisting',
|
||||||
type: ApplicationCommandOptionType.String,
|
type: ApplicationCommandOptionType.String,
|
||||||
required: false
|
required: false
|
||||||
}
|
}
|
||||||
@@ -47,18 +48,18 @@ module.exports = new Command({
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: cmdconfig.Mod.TicketBlacklist.Remove.Command,
|
name: cmdconfig.Mod.TicketBlacklist.Remove.Command,
|
||||||
description: msgconfig.Mod.TicketBlacklist.Remove.Description || "Remove a user from the ticket blacklist",
|
description: cmdconfig.Mod.TicketBlacklist.Remove.Description || "Remove a user from the blacklist",
|
||||||
type: ApplicationCommandOptionType.Subcommand,
|
type: ApplicationCommandOptionType.Subcommand,
|
||||||
options: [
|
options: [
|
||||||
{
|
{
|
||||||
name: 'user',
|
name: 'user',
|
||||||
description: msgconfig.Mod.TicketBlacklist.Remove.UserDescription || "User to remove from blacklist",
|
description: 'The user to remove from the blacklist',
|
||||||
type: ApplicationCommandOptionType.User,
|
type: ApplicationCommandOptionType.User,
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'reason',
|
name: 'reason',
|
||||||
description: msgconfig.Mod.TicketBlacklist.Remove.ReasonDescription || "Reason for removing from blacklist",
|
description: 'Reason for unblacklisting',
|
||||||
type: ApplicationCommandOptionType.String,
|
type: ApplicationCommandOptionType.String,
|
||||||
required: false
|
required: false
|
||||||
}
|
}
|
||||||
@@ -67,60 +68,32 @@ module.exports = new Command({
|
|||||||
{
|
{
|
||||||
name: "view",
|
name: "view",
|
||||||
description: msgconfig.Mod.TicketBlacklist.View.Description || "View all blacklisted users",
|
description: msgconfig.Mod.TicketBlacklist.View.Description || "View all blacklisted users",
|
||||||
type: ApplicationCommandOptionType.Subcommand,
|
type: ApplicationCommandOptionType.Subcommand
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
permissions: cmdconfig.Mod.Permission,
|
|
||||||
|
|
||||||
async run(interaction) {
|
async run(interaction) {
|
||||||
const { getRole } = interaction.client;
|
const subcommandGroup = interaction.options.getSubcommandGroup();
|
||||||
|
const subcommand = interaction.options.getSubcommand();
|
||||||
let SupportStaff = await getRole(supportbot.Roles.StaffMember.Staff, interaction.guild);
|
|
||||||
let Admin = await getRole(supportbot.Roles.StaffMember.Admin, interaction.guild);
|
|
||||||
let Moderator = await getRole(supportbot.Roles.StaffMember.Moderator, interaction.guild);
|
|
||||||
|
|
||||||
if (!SupportStaff || !Admin || !Moderator) {
|
|
||||||
const missingRolesEmbed = new EmbedBuilder()
|
|
||||||
.setDescription(msgconfig.Error.InvalidChannel || "Required roles are missing!")
|
|
||||||
.setColor(supportbot.Embed.Colours.Warn);
|
|
||||||
return interaction.reply({ embeds: [missingRolesEmbed] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const NoPerms = new EmbedBuilder()
|
|
||||||
.setDescription(msgconfig.Error.IncorrectPerms || "You do not have the correct permissions!")
|
|
||||||
.setColor(supportbot.Embed.Colours.Warn);
|
|
||||||
|
|
||||||
if (!interaction.member.roles.cache.has(Admin.id) && !interaction.member.roles.cache.has(Moderator.id) && (!supportbot.Mod.AllowSupportStaff || !interaction.member.roles.cache.has(SupportStaff.id))) {
|
|
||||||
return interaction.reply({ embeds: [NoPerms] });
|
|
||||||
}
|
|
||||||
|
|
||||||
const subcommand = interaction.options.getSubcommand(false);
|
|
||||||
const user = interaction.options.getUser('user');
|
const user = interaction.options.getUser('user');
|
||||||
const reason = interaction.options.getString('reason') || "No reason provided";
|
const reason = interaction.options.getString('reason') || "No reason provided";
|
||||||
|
|
||||||
try {
|
try {
|
||||||
let blacklistedUsers;
|
const blacklistedUsers = typeof db.getBlacklistedUsers === "function" ? db.getBlacklistedUsers() : [];
|
||||||
try {
|
|
||||||
blacklistedUsers = JSON.parse(fs.readFileSync("./Data/BlacklistedUsers.json", "utf8")).blacklistedUsers;
|
|
||||||
if (!Array.isArray(blacklistedUsers)) {
|
|
||||||
blacklistedUsers = [];
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
blacklistedUsers = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (subcommand === cmdconfig.Mod.TicketBlacklist.Add.Command) {
|
if (subcommand === cmdconfig.Mod.TicketBlacklist.Add.Command) {
|
||||||
if (blacklistedUsers.includes(user.id)) {
|
if (db.isUserBlacklisted && db.isUserBlacklisted(user.id)) {
|
||||||
const alreadyBlacklistedEmbed = new EmbedBuilder()
|
const alreadyBlacklistedEmbed = new EmbedBuilder()
|
||||||
.setDescription(msgconfig.Mod.TicketBlacklist.Add.AlreadyBlacklisted.replace("{userTag}", user.tag) || ":x: User is already blacklisted.")
|
.setDescription(msgconfig.Mod.TicketBlacklist.Add.AlreadyBlacklisted.replace("{userTag}", user.tag) || ":x: User is already blacklisted.")
|
||||||
.setColor(supportbot.Embed.Colours.Warn);
|
.setColor(supportbot.Embed.Colours.Warn);
|
||||||
return interaction.reply({ embeds: [alreadyBlacklistedEmbed], flags: MessageFlags.Ephemeral });
|
return interaction.reply({ embeds: [alreadyBlacklistedEmbed], flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
blacklistedUsers.push(user.id);
|
if (typeof db.addBlacklistedUser === "function") {
|
||||||
fs.writeFileSync("./Data/BlacklistedUsers.json", JSON.stringify({ "blacklistedUsers": blacklistedUsers }, null, 4));
|
db.addBlacklistedUser(user.id, reason, interaction.user.tag);
|
||||||
|
}
|
||||||
|
|
||||||
const successEmbed = new EmbedBuilder()
|
const successEmbed = new EmbedBuilder()
|
||||||
.setDescription(msgconfig.Mod.TicketBlacklist.Add.Success.replace("{userTag}", user.tag) || ":white_check_mark: User has been blacklisted.")
|
.setDescription(msgconfig.Mod.TicketBlacklist.Add.Success.replace("{userTag}", user.tag) || ":white_check_mark: User has been blacklisted.")
|
||||||
@@ -133,7 +106,7 @@ module.exports = new Command({
|
|||||||
.setColor(msgconfig.TicketBlacklistLog.Colour || supportbot.Embed.Colours.Success)
|
.setColor(msgconfig.TicketBlacklistLog.Colour || supportbot.Embed.Colours.Success)
|
||||||
.setFooter({ text: supportbot.Embed.Footer, iconURL: interaction.user.displayAvatarURL() })
|
.setFooter({ text: supportbot.Embed.Footer, iconURL: interaction.user.displayAvatarURL() })
|
||||||
.setDescription(`> **User:** ${user.tag} (\`${user.id}\`)\n> **Actioned by:** <@${interaction.user.id}>`)
|
.setDescription(`> **User:** ${user.tag} (\`${user.id}\`)\n> **Actioned by:** <@${interaction.user.id}>`)
|
||||||
.addFields({ name: "Action", value: "\`\`\`Removed from blacklist\`\`\`", inline: false })
|
.addFields({ name: "Action", value: "\`\`\`Added to blacklist\`\`\`", inline: false })
|
||||||
.addFields({ name: "Reason", value: `\`\`\`${reason}\`\`\``, inline: false });
|
.addFields({ name: "Reason", value: `\`\`\`${reason}\`\`\``, inline: false });
|
||||||
|
|
||||||
blacklistChannel.send({ embeds: [blacklistLogEmbed] });
|
blacklistChannel.send({ embeds: [blacklistLogEmbed] });
|
||||||
@@ -145,15 +118,16 @@ module.exports = new Command({
|
|||||||
});
|
});
|
||||||
|
|
||||||
} else if (subcommand === cmdconfig.Mod.TicketBlacklist.Remove.Command) {
|
} else if (subcommand === cmdconfig.Mod.TicketBlacklist.Remove.Command) {
|
||||||
if (!blacklistedUsers.includes(user.id)) {
|
if (db.isUserBlacklisted && !db.isUserBlacklisted(user.id)) {
|
||||||
const notBlacklistedEmbed = new EmbedBuilder()
|
const notBlacklistedEmbed = new EmbedBuilder()
|
||||||
.setDescription(msgconfig.Mod.TicketBlacklist.Remove.NotBlacklisted.replace("{userTag}", user.tag) || ":x: User is not blacklisted.")
|
.setDescription(msgconfig.Mod.TicketBlacklist.Remove.NotBlacklisted.replace("{userTag}", user.tag) || ":x: User is not blacklisted.")
|
||||||
.setColor(supportbot.Embed.Colours.Warn);
|
.setColor(supportbot.Embed.Colours.Warn);
|
||||||
return interaction.reply({ embeds: [notBlacklistedEmbed], flags: MessageFlags.Ephemeral });
|
return interaction.reply({ embeds: [notBlacklistedEmbed], flags: MessageFlags.Ephemeral });
|
||||||
}
|
}
|
||||||
|
|
||||||
blacklistedUsers = blacklistedUsers.filter(id => id !== user.id);
|
if (typeof db.removeBlacklistedUser === "function") {
|
||||||
fs.writeFileSync("./Data/BlacklistedUsers.json", JSON.stringify({ "blacklistedUsers": blacklistedUsers }, null, 4));
|
db.removeBlacklistedUser(user.id);
|
||||||
|
}
|
||||||
|
|
||||||
const removedEmbed = new EmbedBuilder()
|
const removedEmbed = new EmbedBuilder()
|
||||||
.setDescription(msgconfig.Mod.TicketBlacklist.Remove.Success.replace("{userTag}", user.tag) || ":white_check_mark: User has been removed from the blacklist.")
|
.setDescription(msgconfig.Mod.TicketBlacklist.Remove.Success.replace("{userTag}", user.tag) || ":white_check_mark: User has been removed from the blacklist.")
|
||||||
|
|||||||
@@ -1,35 +1,18 @@
|
|||||||
const { Command } = require('../../Structures/Addon.js');
|
const Command = require("../../Structures/Command.js");
|
||||||
const Discord = require("discord.js");
|
const Discord = require("discord.js");
|
||||||
const fs = require("fs");
|
|
||||||
const yaml = require("js-yaml");
|
|
||||||
const db = require("../../Structures/Database.js");
|
const db = require("../../Structures/Database.js");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||||
|
|
||||||
async function loadSettings() {
|
async function loadSettings() {
|
||||||
return new Promise((resolve, reject) => {
|
return typeof db.getSettings === "function" ? db.getSettings() : {};
|
||||||
db.get("SELECT data FROM settings WHERE id = 1", (err, row) => {
|
|
||||||
if (err) return reject(err);
|
|
||||||
if (!row) return resolve({});
|
|
||||||
try {
|
|
||||||
resolve(JSON.parse(row.data));
|
|
||||||
} catch (e) {
|
|
||||||
resolve({});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveSettings(settings) {
|
async function saveSettings(settings) {
|
||||||
const data = JSON.stringify(settings);
|
if (typeof db.saveSettings === "function") {
|
||||||
return new Promise((resolve, reject) => {
|
db.saveSettings(settings);
|
||||||
db.run(
|
}
|
||||||
"INSERT INTO settings (id, data) VALUES (1, ?) ON CONFLICT(id) DO UPDATE SET data = excluded.data",
|
|
||||||
[data],
|
|
||||||
(err) => (err ? reject(err) : resolve())
|
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new Command({
|
module.exports = new Command({
|
||||||
|
|||||||
+57
-2
@@ -1,8 +1,63 @@
|
|||||||
|
# ___ _ ___ _
|
||||||
|
# / __> _ _ ___ ___ ___ _ _ _| |_ | . > ___ _| |_
|
||||||
|
# \__ \| | || . \| . \/ . \| '_> | | | . \/ . \ | |
|
||||||
|
# <___/`___|| _/| _/\___/|_| |_| |___/\___/ |_|
|
||||||
|
# |_| |_|
|
||||||
|
#
|
||||||
|
# SupportBot created by Emerald Services
|
||||||
|
# Installed with MIT License
|
||||||
|
#
|
||||||
|
# Discord Support: https://discord.gg/emeraldsrv
|
||||||
|
# Official Website & Marketplace: https://emeraldsrv.com
|
||||||
|
#
|
||||||
|
# Messages Configuration (api.yml)
|
||||||
|
|
||||||
|
|
||||||
# -------------------------------
|
# -------------------------------
|
||||||
# API Server Configuration
|
# API Server Configuration
|
||||||
# -------------------------------
|
# -------------------------------
|
||||||
|
|
||||||
API:
|
API:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Port: 3000 # PORT NUMBER FOR YOUR BOT
|
Port: 3000
|
||||||
SecretKey: "PUT_RANDOM_STRING_HERE" # DO NOT SHARE THIS KEY WITH ANYONE
|
|
||||||
|
SecretKey: "SECRET_KEY"
|
||||||
|
|
||||||
|
# Set to 1 when the dashboard is behind nginx, Cloudflare, Pterodactyl proxy, etc.
|
||||||
|
# so http/https is detected from X-Forwarded-Proto.
|
||||||
|
|
||||||
|
TrustProxy: false
|
||||||
|
|
||||||
|
EmeraldAPIKey: "EMERALDSRV_API_KEY"
|
||||||
|
# This is required for the one-click addon installer. Get API key from https://emeraldsrv.dev/
|
||||||
|
|
||||||
|
OAuth:
|
||||||
|
Enabled: true
|
||||||
|
ClientId: "CLIENT_ID"
|
||||||
|
ClientSecret: "CLIENT_SECRET"
|
||||||
|
# Get Client ID and Client Secret from https://discord.com/developers/applications
|
||||||
|
|
||||||
|
# Canonical callback URL (host + path). Protocol (http/https) can follow the
|
||||||
|
# browser when UseRequestOrigin is true (default).
|
||||||
|
|
||||||
|
RedirectUri: "http://localhost:3000/api/auth/discord/callback"
|
||||||
|
|
||||||
|
# Optional: register extra callback URLs in Discord and list them here.
|
||||||
|
|
||||||
|
# RedirectUris:
|
||||||
|
# - "https://bot.example.com/api/auth/discord/callback"
|
||||||
|
# UseRequestOrigin: true
|
||||||
|
# CookieSecure: false
|
||||||
|
|
||||||
|
OwnerUserIds:
|
||||||
|
- "OWNER_USER_ID"
|
||||||
|
|
||||||
|
AllowedUserIds: []
|
||||||
|
|
||||||
|
# Dashboard Features
|
||||||
|
|
||||||
|
LiveChat:
|
||||||
|
Mode: "webhook"
|
||||||
|
# "webhook" - Webhook to send messages with staff member's avatar and Discord username
|
||||||
|
# "bot" - Send messages as Bot account with discord username prefix.
|
||||||
|
# "bot_noprefix" - Send messages as Bot account without discord username prefix.
|
||||||
+7
-15
@@ -21,7 +21,7 @@ Info:
|
|||||||
Title: "About Us"
|
Title: "About Us"
|
||||||
Description: "SupportBot is an open-sourced support system for discord. It's completely free to use with high amounts of customization to match your own brand."
|
Description: "SupportBot is an open-sourced support system for discord. It's completely free to use with high amounts of customization to match your own brand."
|
||||||
Button: "Visit Site"
|
Button: "Visit Site"
|
||||||
URL: "https://docs.emeraldsrv.com"
|
URL: "https://emeraldsrv.dev"
|
||||||
Colour: "#45b463"
|
Colour: "#45b463"
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -30,15 +30,15 @@ Info:
|
|||||||
|
|
||||||
Welcome:
|
Welcome:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
LayoutStyle: "modern"
|
LayoutStyle: "classic"
|
||||||
# Available Layout Styles
|
# Available Layout Styles
|
||||||
# "modern" - For the new Discord v2 components
|
# "modern" - For the new Discord v2 components
|
||||||
# "classic" - Use the classic Discord look.
|
# "classic" - Use the classic Discord look.
|
||||||
|
|
||||||
Embed:
|
Embed:
|
||||||
Colour: "#45b463"
|
Colour: "#45b463"
|
||||||
Title: "Welcome to ServerName"
|
Title: "Welcome!"
|
||||||
Message: ":smile: %joined_user% has just landed!\n\n🔨 **Server Rules:** <#1202667199853232129>"
|
Message: ":smile: %joined_user% has just landed!"
|
||||||
Icon:
|
Icon:
|
||||||
Mode: "USER" # USER | BOT | CUSTOM
|
Mode: "USER" # USER | BOT | CUSTOM
|
||||||
CustomURL: "https://example.com/custom.png"
|
CustomURL: "https://example.com/custom.png"
|
||||||
@@ -48,23 +48,15 @@ Welcome:
|
|||||||
Buttons:
|
Buttons:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
List:
|
List:
|
||||||
- label: "BuiltByBit"
|
- label: "Website"
|
||||||
style: "Link"
|
style: "Link"
|
||||||
url: "https://your-website.com/how-to-play-java"
|
url: "https://emeraldsrv.dev"
|
||||||
emoji: "⌨️"
|
emoji: "⌨️"
|
||||||
- label: "GitHub"
|
|
||||||
style: "Link"
|
|
||||||
url: "https://your-website.com/how-to-play-bedrock"
|
|
||||||
emoji: "👾"
|
|
||||||
- label: "Tebex"
|
|
||||||
style: "Link"
|
|
||||||
url: "https://your-store.tebex.io/"
|
|
||||||
emoji: "🛒"
|
|
||||||
|
|
||||||
Modern:
|
Modern:
|
||||||
Colour: "#45b463"
|
Colour: "#45b463"
|
||||||
Title: "**Welcome to ServerName**"
|
Title: "**Welcome to ServerName**"
|
||||||
Body: ":smile: %joined_user% has just landed!\n\n🔨 **Server Rules:** <#1202667199853232129>"
|
Body: ":smile: %joined_user% has just landed!\n\n🔨 **Server Rules:** <#CHANNEL_ID>"
|
||||||
Icon:
|
Icon:
|
||||||
Mode: "BOT" # USER | BOT | CUSTOM
|
Mode: "BOT" # USER | BOT | CUSTOM
|
||||||
CustomURL: "https://example.com/custom-icon.png"
|
CustomURL: "https://example.com/custom-icon.png"
|
||||||
|
|||||||
+24
-13
@@ -8,8 +8,8 @@
|
|||||||
Enabled: true
|
Enabled: true
|
||||||
|
|
||||||
General:
|
General:
|
||||||
Provider: "claude"
|
Provider: "openai"
|
||||||
Model: "claude-sonnet-4-6"
|
Model: "MODEL"
|
||||||
Model_API_Key: "MODEL_API_KEY"
|
Model_API_Key: "MODEL_API_KEY"
|
||||||
# Providers Available:
|
# Providers Available:
|
||||||
# "openai" - Use OpenAI's GPT models. Example: "gpt-5.4" "gpt-5-mini"
|
# "openai" - Use OpenAI's GPT models. Example: "gpt-5.4" "gpt-5-mini"
|
||||||
@@ -17,15 +17,15 @@ General:
|
|||||||
# "claude" - Use Anthropic's Claude models. Example: "claude-sonnet-4-6"
|
# "claude" - Use Anthropic's Claude models. Example: "claude-sonnet-4-6"
|
||||||
|
|
||||||
Tokens: 1200
|
Tokens: 1200
|
||||||
|
|
||||||
Temperature: 0.4
|
Temperature: 0.4
|
||||||
Name: "SupportBot AI"
|
Name: "SupportBot AI"
|
||||||
|
|
||||||
PastebinAPI_Key: "PASTEBIN_API_KEY"
|
PastebinAPI_Key: "YOUR_PASTEBIN_API_KEY"
|
||||||
PastebinAPI_URL: "https://pastebin.com/api/api_post.php"
|
PastebinAPI_URL: "https://pastebin.com/api/api_post.php"
|
||||||
|
|
||||||
Style:
|
Style:
|
||||||
MessageType: "text"
|
MessageType: "components_v2"
|
||||||
# Available Styles for AI:
|
# Available Styles for AI:
|
||||||
# "embed" - AI responses will be sent as Discord embeds.
|
# "embed" - AI responses will be sent as Discord embeds.
|
||||||
# "text" - AI responses will be sent as plain messages.
|
# "text" - AI responses will be sent as plain messages.
|
||||||
@@ -35,7 +35,7 @@ Features:
|
|||||||
SuggestTickets: true # Should SupportBot AI suggest creating a ticket if the user needs more help?
|
SuggestTickets: true # Should SupportBot AI suggest creating a ticket if the user needs more help?
|
||||||
|
|
||||||
Channels:
|
Channels:
|
||||||
AIChannel: "1469028791312715902"
|
AIChannel: "CHANNEL_ID"
|
||||||
|
|
||||||
Embed:
|
Embed:
|
||||||
Color: "#45b463"
|
Color: "#45b463"
|
||||||
@@ -59,12 +59,23 @@ Memory:
|
|||||||
|
|
||||||
CustomInstructions:
|
CustomInstructions:
|
||||||
- "You are SupportBot AI made by Emerald Services"
|
- "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 are built with DiscordJS/NodeJS and currently running on version 26.2"
|
||||||
- "You're current build can be found here https://github.com/Emerald-Services/SupportBot/tree/26.0"
|
- "You're current build can be found here
|
||||||
- "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"
|
https://github.com/Emerald-Services/SupportBot"
|
||||||
- "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."
|
- "You're main build can be found here
|
||||||
- "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."
|
https://builtbybit.com/resources/supportbot-free-advanced-ticket-bot.8940/
|
||||||
- "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."
|
and Polymart at
|
||||||
|
https://polymart.org/product/518/supportbot-1-discord-ticket-bot"
|
||||||
|
- "SupportBot includes all-new AI features such as a memory system that allows
|
||||||
|
you to remember facts and summaries of conversations, which can be used to
|
||||||
|
provide better responses."
|
||||||
|
- "SupportBot AI also has a tickets AI Mode which is enabled in
|
||||||
|
supportbot.yml, 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"
|
#- "Add custom instructions here"
|
||||||
|
|
||||||
Messages:
|
Messages:
|
||||||
@@ -73,4 +84,4 @@ Messages:
|
|||||||
AIDisabled: "AI is currently disabled."
|
AIDisabled: "AI is currently disabled."
|
||||||
MessageTooLong: "My response was too long, so I uploaded it here: {pastebin}"
|
MessageTooLong: "My response was too long, so I uploaded it here: {pastebin}"
|
||||||
SuggestTicketText: "-# Need more help? React with 🎫 to open a ticket."
|
SuggestTicketText: "-# Need more help? React with 🎫 to open a ticket."
|
||||||
SuggestTicketReaction : "🎫"
|
SuggestTicketReaction: "🎫"
|
||||||
+65
-48
@@ -12,7 +12,7 @@
|
|||||||
#
|
#
|
||||||
# SupportBot Configuration (supportbot.yml)
|
# SupportBot Configuration (supportbot.yml)
|
||||||
|
|
||||||
SupportBot_Version: "v26.1 DEV-2"
|
SupportBot_Version: "v26.2"
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# General
|
# General
|
||||||
@@ -20,20 +20,39 @@ SupportBot_Version: "v26.1 DEV-2"
|
|||||||
|
|
||||||
General:
|
General:
|
||||||
Name: "SupportBot"
|
Name: "SupportBot"
|
||||||
Token: "BOT_TOKEN"
|
Token: "YOUR_BOT_TOKEN_HERE"
|
||||||
|
|
||||||
Addons:
|
Addons:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
|
GuildId: "YOUR_GUILD_ID_HERE"
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
# Database Configuration
|
||||||
|
# -------------------------
|
||||||
|
|
||||||
|
Database:
|
||||||
|
# Driver options: "mysql" (works for MariaDB & MySQL) or "sqlite"
|
||||||
|
Driver: "mysql"
|
||||||
|
|
||||||
|
# Connection Settings for MariaDB / MySQL (used when Driver is "mysql" or "mariadb")
|
||||||
|
MySQL:
|
||||||
|
Host: "localhost"
|
||||||
|
Port: 3306
|
||||||
|
User: "root"
|
||||||
|
Password: "password"
|
||||||
|
Database: "supportbot"
|
||||||
|
ConnectionLimit: 10
|
||||||
|
|
||||||
|
# Connection Settings for SQLite (used when Driver is "sqlite")
|
||||||
|
SQLite:
|
||||||
|
File: "./Data/supportbot.db"
|
||||||
|
|
||||||
# Activity
|
# Activity
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|
||||||
Activity:
|
Activity:
|
||||||
Status: "SupportBot v8"
|
Status: "SupportBot"
|
||||||
Type: "Competing"
|
Type: "Watching"
|
||||||
StreamingURL: "https://www.twitch.tv/YOUR_TWITCH_NAME"
|
StreamingURL: "https://www.twitch.tv/YOUR_TWITCH_NAME"
|
||||||
|
|
||||||
# Available Types:
|
# Available Types:
|
||||||
# Playing
|
# Playing
|
||||||
# Watching
|
# Watching
|
||||||
@@ -43,9 +62,9 @@ Activity:
|
|||||||
|
|
||||||
# You can also update the activity via Discord using: /settings
|
# You can also update the activity via Discord using: /settings
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Embeds
|
# Embeds
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|
||||||
Embed:
|
Embed:
|
||||||
Colours:
|
Colours:
|
||||||
@@ -55,12 +74,11 @@ Embed:
|
|||||||
Warn: "#fca117"
|
Warn: "#fca117"
|
||||||
|
|
||||||
Footer: "SupportBot | Developed by Emerald Development"
|
Footer: "SupportBot | Developed by Emerald Development"
|
||||||
|
|
||||||
# Use 0x<hexcode> to change the colour of the embeds.
|
# Use 0x<hexcode> to change the colour of the embeds.
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Roles
|
# Roles
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|
||||||
Roles:
|
Roles:
|
||||||
AutoRole:
|
AutoRole:
|
||||||
@@ -73,20 +91,19 @@ Roles:
|
|||||||
Moderator: "ROLE_ID"
|
Moderator: "ROLE_ID"
|
||||||
|
|
||||||
Mod:
|
Mod:
|
||||||
AllowSupportStaff: false
|
AllowSupportStaff: true
|
||||||
|
|
||||||
# Allow Support Staff to use the /mod command. (More in detail customisation in commands.yml)
|
# Allow Support Staff to use the /mod command. (More in detail customisation in commands.yml)
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
# System Messages
|
# System Messages
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
|
|
||||||
Welcome:
|
Welcome:
|
||||||
Enabled: true
|
Enabled: false
|
||||||
Channel: "CHANNEL_ID"
|
Channel: "CHANNEL_ID"
|
||||||
|
|
||||||
Leave:
|
Leave:
|
||||||
Enabled: true
|
Enabled: false
|
||||||
Channel: "CHANNEL_ID"
|
Channel: "CHANNEL_ID"
|
||||||
|
|
||||||
# ----------------------------
|
# ----------------------------
|
||||||
@@ -110,7 +127,7 @@ Suggestions:
|
|||||||
# If true this will allow the user to vote for a suggestion they created
|
# If true this will allow the user to vote for a suggestion they created
|
||||||
|
|
||||||
ShowUsers: true
|
ShowUsers: true
|
||||||
|
|
||||||
Threads:
|
Threads:
|
||||||
Enabled: true
|
Enabled: true
|
||||||
Title: "Suggestion-releated Thread"
|
Title: "Suggestion-releated Thread"
|
||||||
@@ -121,7 +138,6 @@ Suggestions:
|
|||||||
Downvote: "2"
|
Downvote: "2"
|
||||||
RemoveVote: "4"
|
RemoveVote: "4"
|
||||||
RemoveVote_Title: "Remove Vote"
|
RemoveVote_Title: "Remove Vote"
|
||||||
|
|
||||||
# Available Button Styles
|
# Available Button Styles
|
||||||
# 1 - Blurple
|
# 1 - Blurple
|
||||||
# 2 - Grey
|
# 2 - Grey
|
||||||
@@ -129,21 +145,21 @@ Suggestions:
|
|||||||
# 4 - Red
|
# 4 - Red
|
||||||
|
|
||||||
Tags:
|
Tags:
|
||||||
Created: "TAG_ID"
|
Created: "TAG_ID"
|
||||||
Considering: "TAG_ID"
|
Considering: "TAG_ID"
|
||||||
Solved: "TAG_ID"
|
Solved: "TAG_ID"
|
||||||
Denied: "TAG_ID"
|
Denied: "TAG_ID"
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Tickets
|
# Tickets
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|
||||||
VoiceTickets:
|
VoiceTickets:
|
||||||
Name: "%username%'s VC"
|
Name: "%username%'s VC"
|
||||||
Category: "CATEGORY_ID"
|
Category: "CATEGORY_ID"
|
||||||
|
|
||||||
Ticket:
|
Ticket:
|
||||||
TicketType: "channels"
|
TicketType: "channels"
|
||||||
|
|
||||||
# Available TicketTypes:
|
# Available TicketTypes:
|
||||||
# channels - Tickets will be created as channels in the TicketChannelsCategory
|
# channels - Tickets will be created as channels in the TicketChannelsCategory
|
||||||
@@ -154,14 +170,14 @@ Ticket:
|
|||||||
DMOnRemove: true # DM the user when they are removed from the ticket
|
DMOnRemove: true # DM the user when they are removed from the ticket
|
||||||
DMTranscript: true # DM the user a transcript of the ticket when it is closed
|
DMTranscript: true # DM the user a transcript of the ticket when it is closed
|
||||||
|
|
||||||
TicketHome: "1469028381638000725"
|
TicketHome: "CHANNEL_ID"
|
||||||
|
|
||||||
# What is TicketHome?
|
# What is TicketHome?
|
||||||
# - Channel where the Ticket creation panel is sent.
|
# - Channel where the Ticket creation panel is sent.
|
||||||
# - Channel where ticket threads are made. (only if ticket threads is enabled above.)
|
# - Channel where ticket threads are made. (only if ticket threads is enabled above.)
|
||||||
|
|
||||||
# For TicketType: channels
|
# For TicketType: channels
|
||||||
TicketChannelsCategory: "CATEGORY_ID"
|
TicketChannelsCategory: "CATEGORY_ID"
|
||||||
TicketChannelsCategory2: "CATEGORY_ID" # This will be used if the first category has reached the max channel limit.
|
TicketChannelsCategory2: "CATEGORY_ID" # This will be used if the first category has reached the max channel limit.
|
||||||
TicketArchiveCategory: "CATEGORY_ID"
|
TicketArchiveCategory: "CATEGORY_ID"
|
||||||
# ---
|
# ---
|
||||||
@@ -184,14 +200,15 @@ Ticket:
|
|||||||
StaffOnly: true
|
StaffOnly: true
|
||||||
# Set to false is anyone is able to close a ticket.
|
# Set to false is anyone is able to close a ticket.
|
||||||
|
|
||||||
Invites: # Invites is for ticket threads.
|
Invites:
|
||||||
|
# Invites is for ticket threads.
|
||||||
StaffOnly: true
|
StaffOnly: true
|
||||||
|
|
||||||
ClaimTickets:
|
ClaimTickets:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
Channel: "CHANNEL_ID"
|
Channel: "CHANNEL_ID"
|
||||||
# Channel where the ticket assignments are sent for staff to claim.
|
# Channel where the ticket assignments are sent for staff to claim.
|
||||||
|
|
||||||
ClaimAcceptTime: 120000
|
ClaimAcceptTime: 120000
|
||||||
# How long the assigned user has to accept the ticket before it gets re-assigned to someone else.
|
# How long the assigned user has to accept the ticket before it gets re-assigned to someone else.
|
||||||
# 120000 = 2 Minutes
|
# 120000 = 2 Minutes
|
||||||
@@ -203,7 +220,6 @@ Ticket:
|
|||||||
ButtonTitle: "Claim Ticket"
|
ButtonTitle: "Claim Ticket"
|
||||||
ButtonEmoji: "🎫"
|
ButtonEmoji: "🎫"
|
||||||
Button: "1"
|
Button: "1"
|
||||||
|
|
||||||
# Available Button Styles
|
# Available Button Styles
|
||||||
# 1 - Blurple
|
# 1 - Blurple
|
||||||
# 2 - Grey
|
# 2 - Grey
|
||||||
@@ -211,12 +227,13 @@ Ticket:
|
|||||||
# 4 - Red
|
# 4 - Red
|
||||||
|
|
||||||
ReviewSystem:
|
ReviewSystem:
|
||||||
Enabled: false
|
Enabled: false
|
||||||
Channel: "CHANNEL_ID"
|
Channel: "CHANNEL_ID"
|
||||||
UseModal: true
|
UseModal: true
|
||||||
|
|
||||||
Questions: # Default questions asked when a ticket is created, these are used if departments are disabled.
|
Questions:
|
||||||
Enabled: false
|
# Default questions asked when a ticket is created, these are used if departments are disabled.
|
||||||
|
Enabled: false
|
||||||
List:
|
List:
|
||||||
- "Please provide your account email?"
|
- "Please provide your account email?"
|
||||||
- "What is the nature of your issue?"
|
- "What is the nature of your issue?"
|
||||||
@@ -232,12 +249,12 @@ Ticket:
|
|||||||
ThreadName: "Chat with SupportBot AI"
|
ThreadName: "Chat with SupportBot AI"
|
||||||
WelcomeMessage: "Hi! I’m SupportBot AI. You can chat with me here about your issue."
|
WelcomeMessage: "Hi! I’m SupportBot AI. You can chat with me here about your issue."
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Ticket Departments
|
# Ticket Departments
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|
||||||
DepartmentSystem:
|
DepartmentSystem:
|
||||||
Enabled: true
|
Enabled: false
|
||||||
Placeholder: "Choose a ticket department"
|
Placeholder: "Choose a ticket department"
|
||||||
DefaultDepartment: "general"
|
DefaultDepartment: "general"
|
||||||
|
|
||||||
@@ -339,14 +356,14 @@ Buttons:
|
|||||||
# 3 - Green
|
# 3 - Green
|
||||||
# 4 - Red
|
# 4 - Red
|
||||||
|
|
||||||
# -------------------------
|
# -------------------------
|
||||||
# Select Menus
|
# Select Menus
|
||||||
# -------------------------
|
# -------------------------
|
||||||
|
|
||||||
SelectMenus:
|
SelectMenus:
|
||||||
Tickets:
|
Tickets:
|
||||||
PanelEmoji: "⚙️"
|
PanelEmoji: "⚙️"
|
||||||
|
|
||||||
CloseEmoji: "🗑️"
|
CloseEmoji: "🗑️"
|
||||||
ArchiveEmoji: "♻️"
|
ArchiveEmoji: "♻️"
|
||||||
LockEmoji: "🔐"
|
LockEmoji: "🔐"
|
||||||
|
|||||||
@@ -20,11 +20,12 @@
|
|||||||
Panel: true
|
Panel: true
|
||||||
|
|
||||||
Style:
|
Style:
|
||||||
Title: "## SupportBot Ticket Creation"
|
Title: "## 📬 Open Ticket"
|
||||||
Description: "Click on the button to file a `Ticket` and our team will try their best to assist you in the best way we can!"
|
Description: "Tap the button to open a support ticket, Our team will respond as
|
||||||
Image: "https://i.imgur.com/dejQvkg.png"
|
soon as possible."
|
||||||
|
Image: "https://cdn.1tzemerald.space/Emerald%20Services/SBPoster.png"
|
||||||
Color: "#45b463"
|
Color: "#45b463"
|
||||||
Layout: "4"
|
Layout: "3"
|
||||||
|
|
||||||
# Available Layout Styles
|
# Available Layout Styles
|
||||||
# "1" - Style 1 - Title & Text, Button at the top -- Image at the bottom
|
# "1" - Style 1 - Title & Text, Button at the top -- Image at the bottom
|
||||||
@@ -33,8 +34,8 @@ Style:
|
|||||||
# "4" - Style 4 - Classic Embed (Title & Text, Image on right, Button at bottom)
|
# "4" - Style 4 - Classic Embed (Title & Text, Image on right, Button at bottom)
|
||||||
|
|
||||||
Button:
|
Button:
|
||||||
Text: "Create Ticket"
|
Text: "Open"
|
||||||
Emoji: "🎫"
|
Emoji: "🛟"
|
||||||
Color: "2"
|
Color: "2"
|
||||||
CustomId: "createticket"
|
CustomId: "createticket"
|
||||||
|
|
||||||
@@ -45,7 +46,7 @@ Button:
|
|||||||
# 4 - Green
|
# 4 - Green
|
||||||
|
|
||||||
DepartmentMenu:
|
DepartmentMenu:
|
||||||
CustomId: "ticketdepartmentselect"
|
CustomId: "ticketdepartmentselect"
|
||||||
Placeholder: "Choose a ticket department"
|
Placeholder: "Choose a ticket department"
|
||||||
DefaultDescription: "Open a support ticket"
|
DefaultDescription: "Open a support ticket"
|
||||||
DefaultEmoji: "🎫"
|
DefaultEmoji: "🎫"
|
||||||
@@ -58,7 +59,8 @@ Settings:
|
|||||||
BottomDivider: false
|
BottomDivider: false
|
||||||
|
|
||||||
Messages:
|
Messages:
|
||||||
NotConfigured: "Ticket Panel is not set up. Please configure it in `/Configs/ticket-panel.yml`."
|
NotConfigured: "Ticket Panel is not set up. Please configure it in
|
||||||
|
`/Configs/ticket-panel.yml`."
|
||||||
InvalidChannel: "TicketHome channel not found. Please check your config file."
|
InvalidChannel: "TicketHome channel not found. Please check your config file."
|
||||||
PanelSent: "Ticket panel message has been sent!"
|
PanelSent: "Ticket panel message has been sent!"
|
||||||
PanelSentWithDepartments: "Ticket panel message with department selection has been sent!"
|
PanelSentWithDepartments: "Ticket panel message with department selection has been sent!"
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
Theme:
|
||||||
|
Background: '#0e0e0e'
|
||||||
|
Panel: '#1d1d1d'
|
||||||
|
PanelSecondary: '#222222'
|
||||||
|
PanelTertiary: '#1d2938'
|
||||||
|
Border: '#2e2e2e'
|
||||||
|
Text: '#e6edf3'
|
||||||
|
Muted: '#606060'
|
||||||
|
Accent: '#396ff8'
|
||||||
|
AccentSoft: rgba(34, 197, 94, 0.12)
|
||||||
|
AccentBorder: rgba(34, 197, 94, 0.25)
|
||||||
|
AccentText: '#8df0ad'
|
||||||
|
Link: '#396ff8'
|
||||||
|
AttachmentLink: '#396ff8'
|
||||||
|
CodeBackground: '#0e0e0e'
|
||||||
|
Typography:
|
||||||
|
FontFamily: Poppins
|
||||||
|
Header:
|
||||||
|
Title: Ticket Transcript
|
||||||
|
Subtitle: Exported conversation log for support ticket **{{ticketName}}**
|
||||||
|
Badge: Closed Transcript
|
||||||
|
ShowMetaCards: true
|
||||||
|
Meta:
|
||||||
|
ChannelLabel: Ticket Name
|
||||||
|
TicketIdLabel: Ticket ID
|
||||||
|
MessageCountLabel: Message Count
|
||||||
|
CloseReasonLabel: Close Reason
|
||||||
|
DefaultCloseReason: No Reason Provided.
|
||||||
|
Messages:
|
||||||
|
SectionTitle: Messages
|
||||||
|
EmptyState: No messages were found in this ticket.
|
||||||
|
Advanced:
|
||||||
|
CustomCss: ''
|
||||||
@@ -1,3 +0,0 @@
|
|||||||
{
|
|
||||||
"blacklistedUsers": []
|
|
||||||
}
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
|
|
||||||
@@ -5,9 +5,7 @@ const fs = require("fs");
|
|||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
|
|
||||||
const supportbotai = yaml.load(
|
const supportbotai = require("../../Structures/ConfigStore").supportbotAi;
|
||||||
fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
function extractTextFromMessage(message) {
|
function extractTextFromMessage(message) {
|
||||||
if (!message) return "";
|
if (!message) return "";
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ const fs = require("fs");
|
|||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const OpenAI = require("openai");
|
const OpenAI = require("openai");
|
||||||
|
|
||||||
const supportbotai = yaml.load(
|
const supportbotai = require("../../Structures/ConfigStore").supportbotAi;
|
||||||
fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
const client = new OpenAI({
|
||||||
apiKey: supportbotai.General.Model_API_Key,
|
apiKey: supportbotai.General.Model_API_Key,
|
||||||
|
|||||||
@@ -5,9 +5,7 @@ const fs = require("fs");
|
|||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const OpenAI = require("openai");
|
const OpenAI = require("openai");
|
||||||
|
|
||||||
const supportbotai = yaml.load(
|
const supportbotai = require("../../Structures/ConfigStore").supportbotAi;
|
||||||
fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const client = new OpenAI({
|
const client = new OpenAI({
|
||||||
apiKey: supportbotai.General.Model_API_Key,
|
apiKey: supportbotai.General.Model_API_Key,
|
||||||
|
|||||||
+28
-6
@@ -1,9 +1,31 @@
|
|||||||
const Event = require("../Structures/Event.js");
|
const Event = require("../Structures/Event.js");
|
||||||
module.exports = new Event("guildCreate", async (client) => {
|
const {
|
||||||
console.log(
|
getConfiguredGuildId,
|
||||||
`\u001b[31m`,
|
syncGuildHealth,
|
||||||
`${client.user.username} is not in the correct server set in your config. Please leave all other servers and restart the bot.`
|
logGuildWarning,
|
||||||
|
} = require("../Structures/GuildManager.js");
|
||||||
|
|
||||||
|
module.exports = new Event("guildCreate", async (client, guild) => {
|
||||||
|
const configuredId = getConfiguredGuildId();
|
||||||
|
|
||||||
|
if (!configuredId) {
|
||||||
|
logGuildWarning(
|
||||||
|
client,
|
||||||
|
`Joined "${guild.name}" (${guild.id}). Set General.GuildId in config to lock the bot to one server.`,
|
||||||
|
);
|
||||||
|
syncGuildHealth(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (guild.id === configuredId) {
|
||||||
|
syncGuildHealth(client);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
logGuildWarning(
|
||||||
|
client,
|
||||||
|
`Joined unauthorized server "${guild.name}" (${guild.id}). Leave it from the dashboard or remove the invite.`,
|
||||||
);
|
);
|
||||||
console.log(`\u001b[31m`, `${client.user.username} will now exit.`);
|
|
||||||
return process.exit(1);
|
syncGuildHealth(client);
|
||||||
});
|
});
|
||||||
+20
-7
@@ -1,10 +1,23 @@
|
|||||||
const Event = require("../Structures/Event.js");
|
const Event = require("../Structures/Event.js");
|
||||||
|
const {
|
||||||
|
getConfiguredGuildId,
|
||||||
|
syncGuildHealth,
|
||||||
|
logGuildWarning,
|
||||||
|
} = require("../Structures/GuildManager.js");
|
||||||
|
|
||||||
module.exports = new Event("guildDelete", async (client) => {
|
module.exports = new Event("guildDelete", async (client, guild) => {
|
||||||
console.log(
|
const configuredId = getConfiguredGuildId();
|
||||||
`\u001b[31m`,
|
|
||||||
`${client.user.username} is not in the correct server set in your config. Please join the server and restart the bot.`
|
if (configuredId && guild.id === configuredId) {
|
||||||
);
|
logGuildWarning(
|
||||||
console.log(`\u001b[31m`, `${client.user.username} will now exit.`);
|
client,
|
||||||
return process.exit(1);
|
`Left the configured server "${guild.name}" (${guild.id}). Re-invite the bot or update General.GuildId.`,
|
||||||
|
);
|
||||||
|
} else if (!configuredId) {
|
||||||
|
logGuildWarning(client, `Left server "${guild.name}" (${guild.id}).`);
|
||||||
|
} else {
|
||||||
|
logGuildWarning(client, `Left server "${guild.name}" (${guild.id}).`);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncGuildHealth(client);
|
||||||
});
|
});
|
||||||
+15
-18
@@ -8,18 +8,12 @@ const {
|
|||||||
TextDisplayBuilder,
|
TextDisplayBuilder,
|
||||||
SectionBuilder,
|
SectionBuilder,
|
||||||
ThumbnailBuilder,
|
ThumbnailBuilder,
|
||||||
AttachmentBuilder
|
MediaGalleryBuilder
|
||||||
} = require("discord.js");
|
} = require("discord.js");
|
||||||
const fs = require("fs");
|
|
||||||
const yaml = require("js-yaml");
|
|
||||||
const Event = require("../Structures/Event.js");
|
const Event = require("../Structures/Event.js");
|
||||||
|
|
||||||
const supportbot = yaml.load(
|
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||||
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||||
);
|
|
||||||
const msgconfig = yaml.load(
|
|
||||||
fs.readFileSync("./Configs/messages.yml", "utf8")
|
|
||||||
);
|
|
||||||
|
|
||||||
function getWelcomeThumbnail(client, member, iconCfg) {
|
function getWelcomeThumbnail(client, member, iconCfg) {
|
||||||
switch ((iconCfg?.Mode || "").toUpperCase()) {
|
switch ((iconCfg?.Mode || "").toUpperCase()) {
|
||||||
@@ -35,12 +29,12 @@ function getWelcomeThumbnail(client, member, iconCfg) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildModernComponents(client, member) {
|
function buildModernComponents(client, member) {
|
||||||
const container = new ContainerBuilder()
|
const container = new ContainerBuilder();
|
||||||
|
|
||||||
if (msgconfig.Welcome.Modern.Colour) {
|
if (msgconfig.Welcome.Modern.Colour) {
|
||||||
container .setAccentColor(parseInt(msgconfig.Welcome.Modern.Colour.replace("#", ""), 16));
|
container.setAccentColor(parseInt(msgconfig.Welcome.Modern.Colour.replace("#", ""), 16));
|
||||||
}
|
}
|
||||||
|
|
||||||
const section = new SectionBuilder().addTextDisplayComponents(
|
const section = new SectionBuilder().addTextDisplayComponents(
|
||||||
new TextDisplayBuilder().setContent(
|
new TextDisplayBuilder().setContent(
|
||||||
msgconfig.Welcome.Modern.Title.replace(/%joined_user%/g, member.user)
|
msgconfig.Welcome.Modern.Title.replace(/%joined_user%/g, member.user)
|
||||||
@@ -60,11 +54,16 @@ function buildModernComponents(client, member) {
|
|||||||
|
|
||||||
container.addSectionComponents(section);
|
container.addSectionComponents(section);
|
||||||
|
|
||||||
|
// FIXED: Using MediaGalleryBuilder instead of TextDisplayBuilder for images
|
||||||
if (msgconfig.Welcome.Modern.Image?.Enabled && msgconfig.Welcome.Modern.Image.URL) {
|
if (msgconfig.Welcome.Modern.Image?.Enabled && msgconfig.Welcome.Modern.Image.URL) {
|
||||||
container.addTextDisplayComponents(
|
container.addMediaGalleryComponents(
|
||||||
new TextDisplayBuilder().setContent(
|
new MediaGalleryBuilder().addItems([
|
||||||
``
|
{
|
||||||
)
|
media: {
|
||||||
|
url: msgconfig.Welcome.Modern.Image.URL
|
||||||
|
}
|
||||||
|
}
|
||||||
|
])
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,7 +80,6 @@ function buildModernComponents(client, member) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
container.addActionRowComponents(row);
|
container.addActionRowComponents(row);
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return [container];
|
return [container];
|
||||||
@@ -150,5 +148,4 @@ module.exports = new Event("guildMemberAdd", async (client, member) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
console.log("\u001b[32m", "[+]", "\u001b[33m", `${member.user.username} joined the server!`);
|
console.log("\u001b[32m", "[+]", "\u001b[33m", `${member.user.username} joined the server!`);
|
||||||
|
|
||||||
});
|
});
|
||||||
@@ -3,8 +3,8 @@ const fs = require("fs");
|
|||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const Event = require("../Structures/Event.js");
|
const Event = require("../Structures/Event.js");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
module.exports = new Event("guildMemberRemove", async (client, member) => {
|
module.exports = new Event("guildMemberRemove", async (client, member) => {
|
||||||
|
|
||||||
|
|||||||
+53
-58
@@ -1,12 +1,8 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const Discord = require("discord.js");
|
const Discord = require("discord.js");
|
||||||
const yaml = require("js-yaml");
|
|
||||||
|
|
||||||
const supportbot = yaml.load(
|
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||||
fs.readFileSync("./Configs/supportbot.yml", "utf8"),
|
const cmdconfig = require("../Structures/ConfigStore").commands;
|
||||||
);
|
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
|
||||||
|
|
||||||
const Event = require("../Structures/Event.js");
|
const Event = require("../Structures/Event.js");
|
||||||
const db = require("../Structures/Database.js");
|
const db = require("../Structures/Database.js");
|
||||||
@@ -69,6 +65,7 @@ module.exports = new Event("interactionCreate", async (client, interaction) => {
|
|||||||
"\u001b[37;1m",
|
"\u001b[37;1m",
|
||||||
`${interaction.user.username} has executed ${command.name}`,
|
`${interaction.user.username} has executed ${command.name}`,
|
||||||
);
|
);
|
||||||
|
|
||||||
await command.run(interaction);
|
await command.run(interaction);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -143,6 +140,13 @@ module.exports = new Event("interactionCreate", async (client, interaction) => {
|
|||||||
if (interaction.isStringSelectMenu()) {
|
if (interaction.isStringSelectMenu()) {
|
||||||
if (interaction.customId === "ticketdepartmentselect") {
|
if (interaction.customId === "ticketdepartmentselect") {
|
||||||
const selectedDepartment = interaction.values[0];
|
const selectedDepartment = interaction.values[0];
|
||||||
|
const deptConfig = supportbot.Ticket?.DepartmentSystem?.Departments?.[selectedDepartment];
|
||||||
|
if (deptConfig && deptConfig.Enabled === false) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: "Ticket creation for this department is currently disabled.",
|
||||||
|
flags: Discord.MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (supportbot.Ticket.TicketReason) {
|
if (supportbot.Ticket.TicketReason) {
|
||||||
const modal = new Discord.ModalBuilder()
|
const modal = new Discord.ModalBuilder()
|
||||||
@@ -683,7 +687,15 @@ module.exports = new Event("interactionCreate", async (client, interaction) => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (interaction.customId === "createticket") {
|
if (interaction.customId === "createticket" || interaction.customId === "openticket") {
|
||||||
|
const panelDisabled = panelconfig.Panel === false || supportbot.Ticket?.Enabled === false;
|
||||||
|
if (panelDisabled) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: "Ticket creation is currently disabled.",
|
||||||
|
flags: Discord.MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (supportbot.Ticket.TicketReason) {
|
if (supportbot.Ticket.TicketReason) {
|
||||||
const modal = new Discord.ModalBuilder()
|
const modal = new Discord.ModalBuilder()
|
||||||
.setCustomId("ticketReasonModal")
|
.setCustomId("ticketReasonModal")
|
||||||
@@ -855,69 +867,52 @@ module.exports = new Event("interactionCreate", async (client, interaction) => {
|
|||||||
interaction.customId === "downvote" ||
|
interaction.customId === "downvote" ||
|
||||||
interaction.customId === "removevote"
|
interaction.customId === "removevote"
|
||||||
) {
|
) {
|
||||||
const suggestion = suggestions[interaction.message.id];
|
const suggestion = (typeof db.getSuggestion === "function" ? db.getSuggestion(interaction.message.id) : null) || {
|
||||||
if (!suggestion)
|
id: interaction.message.id,
|
||||||
return interaction.reply({
|
author_id: "",
|
||||||
content: "Suggestion not found.",
|
text: "",
|
||||||
ephemeral: true,
|
status: "Created",
|
||||||
});
|
upvotes: [],
|
||||||
|
downvotes: [],
|
||||||
|
};
|
||||||
|
|
||||||
const userId = interaction.user.id;
|
const userId = interaction.user.id;
|
||||||
const hasUpvoted = suggestion.upvotes.includes(userId);
|
|
||||||
const hasDownvoted = suggestion.downvotes.includes(userId);
|
|
||||||
|
|
||||||
if (interaction.customId === "upvote") {
|
if (supportbot.Suggestions?.OwnSuggestion === false && suggestion.author_id === userId) {
|
||||||
if (hasUpvoted) {
|
return interaction.reply({
|
||||||
suggestion.upvotes = suggestion.upvotes.filter((id) => id !== userId);
|
content: "You cannot vote on your own suggestion.",
|
||||||
} else {
|
flags: Discord.MessageFlags.Ephemeral,
|
||||||
suggestion.upvotes.push(userId);
|
});
|
||||||
if (hasDownvoted) {
|
|
||||||
suggestion.downvotes = suggestion.downvotes.filter(
|
|
||||||
(id) => id !== userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (interaction.customId === "downvote") {
|
|
||||||
if (hasDownvoted) {
|
|
||||||
suggestion.downvotes = suggestion.downvotes.filter(
|
|
||||||
(id) => id !== userId,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
suggestion.downvotes.push(userId);
|
|
||||||
if (hasUpvoted) {
|
|
||||||
suggestion.upvotes = suggestion.upvotes.filter(
|
|
||||||
(id) => id !== userId,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (interaction.customId === "removevote") {
|
|
||||||
suggestion.upvotes = suggestion.upvotes.filter((id) => id !== userId);
|
|
||||||
suggestion.downvotes = suggestion.downvotes.filter(
|
|
||||||
(id) => id !== userId,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fs.writeFileSync(
|
const voteResult = typeof db.voteSuggestion === "function"
|
||||||
"./Data/SuggestionData.json",
|
? db.voteSuggestion(interaction.message.id, userId, interaction.customId)
|
||||||
JSON.stringify(suggestions, null, 2),
|
: { upvoteCount: 0, downvoteCount: 0 };
|
||||||
);
|
|
||||||
|
|
||||||
const upvoteCount = suggestion.upvotes.length;
|
const oldEmbed = interaction.message.embeds?.[0];
|
||||||
const downvoteCount = suggestion.downvotes.length;
|
if (!oldEmbed) {
|
||||||
|
return interaction.reply({
|
||||||
|
content: "Suggestion embed not found.",
|
||||||
|
flags: Discord.MessageFlags.Ephemeral,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
const updatedEmbed = new Discord.EmbedBuilder(
|
const suggestionText = suggestion.text || oldEmbed.fields?.find((f) => f.name === "Suggestion")?.value || "";
|
||||||
interaction.message.embeds[0].data,
|
const authorText = suggestion.author_id
|
||||||
).setFields([
|
? `<@${suggestion.author_id}>`
|
||||||
{ name: "Suggestion", value: suggestion.suggestion, inline: true },
|
: (oldEmbed.fields?.find((f) => f.name === "From")?.value || `<@${userId}>`);
|
||||||
{ name: "From", value: `<@${suggestion.author}>` },
|
|
||||||
|
const updatedEmbed = Discord.EmbedBuilder.from(oldEmbed).setFields([
|
||||||
|
{ name: "Suggestion", value: suggestionText, inline: true },
|
||||||
|
{ name: "From", value: authorText },
|
||||||
{
|
{
|
||||||
name: `${supportbot.Suggestions.UpvoteEmoji} ${supportbot.Suggestions.UpvoteTitle}`,
|
name: `${supportbot.Suggestions.UpvoteEmoji} ${supportbot.Suggestions.UpvoteTitle}`,
|
||||||
value: `${upvoteCount}`,
|
value: `${voteResult.upvoteCount}`,
|
||||||
inline: true,
|
inline: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: `${supportbot.Suggestions.DownvoteEmoji} ${supportbot.Suggestions.DownvoteTitle}`,
|
name: `${supportbot.Suggestions.DownvoteEmoji} ${supportbot.Suggestions.DownvoteTitle}`,
|
||||||
value: `${downvoteCount}`,
|
value: `${voteResult.downvoteCount}`,
|
||||||
inline: true,
|
inline: true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|||||||
+88
-8
@@ -1,14 +1,8 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const Discord = require("discord.js");
|
const Discord = require("discord.js");
|
||||||
const yaml = require("js-yaml");
|
|
||||||
const axios = require("axios");
|
const axios = require("axios");
|
||||||
|
|
||||||
const supportbotai = yaml.load(
|
const supportbotai = require("../Structures/ConfigStore").supportbotAi;
|
||||||
fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"),
|
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||||
);
|
|
||||||
const supportbot = yaml.load(
|
|
||||||
fs.readFileSync("./Configs/supportbot.yml", "utf8"),
|
|
||||||
);
|
|
||||||
|
|
||||||
const Event = require("../Structures/Event.js");
|
const Event = require("../Structures/Event.js");
|
||||||
const AIDatabase = require("../Structures/AIDatabase.js");
|
const AIDatabase = require("../Structures/AIDatabase.js");
|
||||||
@@ -398,6 +392,92 @@ function rememberUserFacts(message) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
module.exports = new Event("messageCreate", async (client, message) => {
|
module.exports = new Event("messageCreate", async (client, message) => {
|
||||||
|
if (message.guild && typeof db.getTicket === "function") {
|
||||||
|
const activeTicket = db.getTicket(message.channel.id);
|
||||||
|
if (activeTicket && global.apiServer) {
|
||||||
|
global.apiServer.broadcast("ticket_message", {
|
||||||
|
ticketId: message.channel.id,
|
||||||
|
message: {
|
||||||
|
id: message.id,
|
||||||
|
author: {
|
||||||
|
id: message.author.id,
|
||||||
|
username: message.author.username,
|
||||||
|
globalName: message.author.globalName || message.author.username,
|
||||||
|
avatar: message.author.displayAvatarURL({ extension: "png", size: 64 }),
|
||||||
|
bot: message.author.bot
|
||||||
|
},
|
||||||
|
content: (() => {
|
||||||
|
if (message.content && message.content.trim()) return message.content;
|
||||||
|
if (Array.isArray(message.components) && message.components.length > 0) {
|
||||||
|
const texts = [];
|
||||||
|
for (const comp of message.components) {
|
||||||
|
const data = typeof comp.toJSON === "function" ? comp.toJSON() : comp;
|
||||||
|
if (data.content) texts.push(data.content);
|
||||||
|
if (Array.isArray(data.components)) {
|
||||||
|
for (const child of data.components) {
|
||||||
|
const childData = typeof child.toJSON === "function" ? child.toJSON() : child;
|
||||||
|
if (childData.content) texts.push(childData.content);
|
||||||
|
if (childData.label && (childData.type === 10 || childData.type === "TEXT_DISPLAY")) {
|
||||||
|
texts.push(childData.label);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (texts.length > 0) return texts.join("\n");
|
||||||
|
}
|
||||||
|
return message.content || "";
|
||||||
|
})(),
|
||||||
|
containerColor: (() => {
|
||||||
|
if (Array.isArray(message.components)) {
|
||||||
|
for (const comp of message.components) {
|
||||||
|
const data = typeof comp.toJSON === "function" ? comp.toJSON() : comp;
|
||||||
|
if (data.accent_color) {
|
||||||
|
return `#${data.accent_color.toString(16).padStart(6, "0")}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
})(),
|
||||||
|
embeds: (message.embeds || []).map(e => ({
|
||||||
|
title: e.title || null,
|
||||||
|
description: e.description || null,
|
||||||
|
color: e.color ? `#${e.color.toString(16).padStart(6, "0")}` : "#5865F2",
|
||||||
|
author: e.author ? { name: e.author.name, iconURL: e.author.iconURL } : null,
|
||||||
|
fields: (e.fields || []).map(f => ({ name: f.name, value: f.value, inline: f.inline })),
|
||||||
|
footer: e.footer ? { text: e.footer.text, iconURL: e.footer.iconURL } : null,
|
||||||
|
thumbnail: e.thumbnail?.url ? { url: e.thumbnail.url } : null,
|
||||||
|
image: e.image?.url ? { url: e.image.url } : null,
|
||||||
|
timestamp: e.timestamp || null
|
||||||
|
})),
|
||||||
|
components: (message.components || []).map(c => {
|
||||||
|
const data = typeof c.toJSON === "function" ? c.toJSON() : c;
|
||||||
|
return {
|
||||||
|
type: data.type,
|
||||||
|
components: (data.components || []).map(sub => {
|
||||||
|
const subData = typeof sub.toJSON === "function" ? sub.toJSON() : sub;
|
||||||
|
return {
|
||||||
|
type: subData.type,
|
||||||
|
label: subData.label || subData.content || null,
|
||||||
|
style: subData.style || null,
|
||||||
|
url: subData.url || null,
|
||||||
|
customId: subData.custom_id || subData.customId || null,
|
||||||
|
emoji: subData.emoji ? (subData.emoji.name || subData.emoji.id) : null
|
||||||
|
};
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
attachments: Array.from((message.attachments || []).values()).map(a => ({
|
||||||
|
id: a.id,
|
||||||
|
name: a.name,
|
||||||
|
url: a.url,
|
||||||
|
contentType: a.contentType
|
||||||
|
})),
|
||||||
|
createdAt: message.createdTimestamp
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (message.author.bot || !message.guild) return;
|
if (message.author.bot || !message.guild) return;
|
||||||
|
|
||||||
let shouldRespond =
|
let shouldRespond =
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ const fs = require("fs");
|
|||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const Event = require("../Structures/Event.js");
|
const Event = require("../Structures/Event.js");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
module.exports = new Event("messageDelete", async (client, message) => {
|
module.exports = new Event("messageDelete", async (client, message) => {
|
||||||
if (!message.guild) return;
|
if (!message.guild) return;
|
||||||
|
|||||||
@@ -1,13 +1,6 @@
|
|||||||
const fs = require("fs");
|
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||||
const yaml = require("js-yaml");
|
const supportbotai = require("../Structures/ConfigStore").supportbotAi;
|
||||||
|
const cmdconfig = require("../Structures/ConfigStore").commands;
|
||||||
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");
|
const Event = require("../Structures/Event.js");
|
||||||
|
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ const yaml = require("js-yaml");
|
|||||||
const Event = require("../Structures/Event.js");
|
const Event = require("../Structures/Event.js");
|
||||||
|
|
||||||
// Load the configuration files
|
// Load the configuration files
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||||
|
|
||||||
module.exports = new Event("messageUpdate", async (client, oldMessage, newMessage) => {
|
module.exports = new Event("messageUpdate", async (client, oldMessage, newMessage) => {
|
||||||
|
|
||||||
|
|||||||
+5
-123
@@ -1,126 +1,8 @@
|
|||||||
const fs = require("fs");
|
|
||||||
const Discord = require("discord.js");
|
|
||||||
const yaml = require("js-yaml");
|
|
||||||
const Event = require("../Structures/Event.js");
|
const Event = require("../Structures/Event.js");
|
||||||
|
const { runBotReady } = require("../Structures/BotStartup.js");
|
||||||
// Load configs
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
|
||||||
|
|
||||||
const ACTIVITY_TYPES = {
|
|
||||||
PLAYING: Discord.ActivityType.Playing,
|
|
||||||
WATCHING: Discord.ActivityType.Watching,
|
|
||||||
LISTENING: Discord.ActivityType.Listening,
|
|
||||||
COMPETING: Discord.ActivityType.Competing
|
|
||||||
};
|
|
||||||
|
|
||||||
const setActivity = (client, type, status) => {
|
|
||||||
const activityType = ACTIVITY_TYPES[type?.toUpperCase()];
|
|
||||||
if (!activityType) return;
|
|
||||||
|
|
||||||
client.user.setPresence({
|
|
||||||
activities: [{ name: status || "Online", type: activityType }],
|
|
||||||
status: "online"
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
// simple colors
|
|
||||||
const c = {
|
|
||||||
reset: "\x1b[0m",
|
|
||||||
gray: "\x1b[90m",
|
|
||||||
green: "\x1b[32m",
|
|
||||||
yellow: "\x1b[33m",
|
|
||||||
cyan: "\x1b[36m",
|
|
||||||
bold: "\x1b[1m"
|
|
||||||
};
|
|
||||||
|
|
||||||
function line() {
|
|
||||||
console.log(`${c.gray}────────────────────────────────────────────────────────${c.reset}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function section(title) {
|
|
||||||
console.log(`\n${c.green}${title}${c.reset}`);
|
|
||||||
console.log(`${c.gray}${"-".repeat(title.length)}${c.reset}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function info(label, value) {
|
|
||||||
console.log(`${c.gray}${label.padEnd(14)}${c.reset} ${value}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
function status(label, ok, details = "") {
|
|
||||||
const icon = ok ? `${c.green}✓${c.reset}` : `${c.yellow}!${c.reset}`;
|
|
||||||
console.log(` ${icon} ${label}${details ? ` (${details})` : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const logStartup = async (client) => {
|
|
||||||
console.clear();
|
|
||||||
|
|
||||||
const botName = supportbot?.General?.Name || "SupportBot";
|
|
||||||
const botVersion = supportbot?.SupportBot_Version || "Unknown";
|
|
||||||
|
|
||||||
const inviteUrl = `https://discord.com/api/oauth2/authorize?client_id=${client.user.id}&permissions=8&scope=bot%20applications.commands`;
|
|
||||||
|
|
||||||
line();
|
|
||||||
console.log(`${c.bold}${c.green}SupportBot${c.reset}`);
|
|
||||||
console.log(`${c.gray}Emerald Services${c.reset}`);
|
|
||||||
line();
|
|
||||||
|
|
||||||
section("System");
|
|
||||||
info("Bot Name", botName);
|
|
||||||
info("Version", botVersion);
|
|
||||||
info("Node.js", process.version);
|
|
||||||
info("Discord.js", `v${Discord.version}`);
|
|
||||||
info("Logged in as", client.user.tag);
|
|
||||||
info("Guilds", `${client.guilds.cache.size}`);
|
|
||||||
|
|
||||||
section("Links");
|
|
||||||
info("Docs", "https://emerald-services.gitbook.io/");
|
|
||||||
info("Discord", "https://dsc.gg/emerald-dev");
|
|
||||||
info("Invite", inviteUrl);
|
|
||||||
|
|
||||||
console.log("");
|
|
||||||
};
|
|
||||||
|
|
||||||
const checkConfigs = async (client) => {
|
|
||||||
const guild = client.guilds.cache.first();
|
|
||||||
if (!guild) return;
|
|
||||||
|
|
||||||
section("Config Check");
|
|
||||||
|
|
||||||
const roles = [
|
|
||||||
supportbot?.Roles?.StaffMember?.Admin,
|
|
||||||
supportbot?.Roles?.StaffMember?.Staff,
|
|
||||||
supportbot?.Roles?.AutoRole?.Role
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
const channels = [
|
|
||||||
supportbot?.Ticket?.TicketHome
|
|
||||||
].filter(Boolean);
|
|
||||||
|
|
||||||
const [roleResults, channelResults] = await Promise.all([
|
|
||||||
Promise.all(roles.map((role) => client.getRole(role, guild))),
|
|
||||||
Promise.all(channels.map((channel) => client.getChannel(channel, guild)))
|
|
||||||
]);
|
|
||||||
|
|
||||||
const missingRoles = roles.filter((_, i) => !roleResults[i]);
|
|
||||||
const missingChannels = channels.filter((_, i) => !channelResults[i]);
|
|
||||||
|
|
||||||
if (!missingRoles.length && !missingChannels.length) {
|
|
||||||
status("All configurations validated successfully", true);
|
|
||||||
} else {
|
|
||||||
if (missingRoles.length) {
|
|
||||||
status("Missing roles", false, missingRoles.join(", "));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (missingChannels.length) {
|
|
||||||
status("Missing channels", false, missingChannels.join(", "));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("");
|
|
||||||
};
|
|
||||||
|
|
||||||
module.exports = new Event("clientReady", async (client) => {
|
module.exports = new Event("clientReady", async (client) => {
|
||||||
setActivity(client, supportbot?.Activity?.Type, supportbot?.Activity?.Status);
|
// restartDiscordBot runs startup manually — clientReady often does not re-fire anyway
|
||||||
await logStartup(client);
|
if (client.__explicitStartup) return;
|
||||||
await checkConfigs(client);
|
await runBotReady(client, { clearConsole: true, logCommands: false });
|
||||||
});
|
});
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
<h1 align="center">
|
<h1 align="center">
|
||||||
<br>
|
<br>
|
||||||
<a href="https://github.com/Emerald-Services/SupportBot"><img src="https://i.imgur.com/cuadAYv.png"></a>
|
<a href="https://github.com/Emerald-Services/SupportBot"><img src="https://cdn.1tzemerald.space/Emerald%20Services/SBThreadHeader.png"></a>
|
||||||
<br>
|
<br>
|
||||||
SupportBot
|
SupportBot
|
||||||
<br>
|
<br>
|
||||||
@@ -28,18 +28,15 @@
|
|||||||
|
|
||||||
| SupportBot | Node.JS | Discord.JS | Supported |
|
| SupportBot | Node.JS | Discord.JS | Supported |
|
||||||
|------------|---------|------------|------------|
|
|------------|---------|------------|------------|
|
||||||
| 8.x | 22.x | 14.x. | ✅ Yes
|
| 26.x | 22.x | 14.x | ✅ Yes |
|
||||||
| 7.7.3 | 16.x | 13.x. | ❌ No
|
| 8.x | 22.x | 14.x | ❌ No |
|
||||||
| 7.x | 16.x | 13.x. | ❌ No
|
| 7.x | 16.x | 13.x | ❌ No |
|
||||||
| 6.x | 14.x | 13.x. | ❌ No
|
| Older | 10.x | 13.x | ❌ No |
|
||||||
| 5.x | 12.x | 13.x. | ❌ No
|
|
||||||
| 4.x | 11.x | 13.x. | ❌ No
|
|
||||||
| Older | 10.x | 13.x. | ❌ No
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://emeraldsrv.com/">Website</a>
|
<a href="https://emeraldsrv.dev/">Website</a>
|
||||||
•
|
•
|
||||||
<a href="https://github.com/Emerald-Services/SupportBot/wiki">Wiki</a>
|
<a href="https://github.com/Emerald-Services/SupportBot/wiki">Wiki</a>
|
||||||
<br>
|
<br>
|
||||||
@@ -60,7 +57,7 @@ SupportBot is an Open-Source discord bot built with [discord.js](https://github.
|
|||||||
|
|
||||||
## Support
|
## Support
|
||||||
|
|
||||||
<a href="https://emeraldsrv.com/discord">
|
<a href="https://emeraldsrv.dev/discord">
|
||||||
<img alt="Discord" src="https://img.shields.io/discord/597779408295821323?logo=discord&logoColor=white&style=for-the-badge">
|
<img alt="Discord" src="https://img.shields.io/discord/597779408295821323?logo=discord&logoColor=white&style=for-the-badge">
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
@@ -72,10 +69,9 @@ Released under the [MIT](https://opensource.org/licenses/MIT) license.
|
|||||||
|
|
||||||
## Third-Party
|
## Third-Party
|
||||||
|
|
||||||
- [Official addons repository](https://github.com/Emerald-Services/Addons/)
|
- [Verified Addons](https://emeraldsrv.dev)
|
||||||
- [Syphers Addons](https://github.com/SypherRed/SB_Addons_Unofficial)
|
- [BuiltByBit](https://builtbybit.com/resources/supportbot-advanced-ticket-bot.8940/)
|
||||||
- [BuiltByBit]([https://builtbybit.com/supportbot](https://builtbybit.com/resources/supportbot-advanced-ticket-bot.8940/))
|
- [Voxel](https://polymart.org/resource/supportbot-1-discord-ticket-bot.518)
|
||||||
- [Polymart](https://polymart.org/resource/supportbot-1-discord-ticket-bot.518)
|
|
||||||
|
|
||||||
|
|
||||||
## Contributors
|
## Contributors
|
||||||
@@ -86,11 +82,4 @@ Released under the [MIT](https://opensource.org/licenses/MIT) license.
|
|||||||
|
|
||||||
## Hosting
|
## Hosting
|
||||||
|
|
||||||
You can use our [Pterodactyl egg](https://github.com/Emerald-Services/Pterodactyl-Egg) to host it on your own panel. We currently don't offer hosting for you, altough we might add this in the future.
|
You can use our [Pterodactyl egg](https://github.com/Emerald-Services/Pterodactyl-Egg) to host SupportBot on your own panel.
|
||||||
If you don't know how to install pterodactyl, you could always host it on replit:
|
|
||||||
|
|
||||||
[]([https://repl.it/github/ItsNyoty/iki](https://repl.it/github/Emerald-Services/SupportBot
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
-20
@@ -1,20 +0,0 @@
|
|||||||
# Security Policy
|
|
||||||
|
|
||||||
## Supported Versions
|
|
||||||
|
|
||||||
Use this section to tell people about which versions of your project are
|
|
||||||
currently being supported with security updates.
|
|
||||||
|
|
||||||
| Version | Supported |
|
|
||||||
| ------- | ------------------ |
|
|
||||||
| 7.7.2 | :white_check_mark: |
|
|
||||||
| 7.7.1 | :white_check_mark: |
|
|
||||||
| < 4.0 | :x: |
|
|
||||||
|
|
||||||
## Reporting a Vulnerability
|
|
||||||
|
|
||||||
Use this section to tell people how to report a vulnerability.
|
|
||||||
|
|
||||||
Tell them where to go, how often they can expect to get an update on a
|
|
||||||
reported vulnerability, what to expect if the vulnerability is accepted or
|
|
||||||
declined, etc.
|
|
||||||
+80
-351
@@ -1,438 +1,167 @@
|
|||||||
const Database = require("better-sqlite3");
|
const fs = require('fs');
|
||||||
const path = require("path");
|
const path = require('path');
|
||||||
|
|
||||||
const db = new Database(path.join(__dirname, "../Data/ai-memory.db"));
|
const DB_PATH = path.join(__dirname, '../Data/ai-memory.json');
|
||||||
|
|
||||||
db.pragma("journal_mode = WAL");
|
function load() {
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(DB_PATH, 'utf8');
|
||||||
|
return JSON.parse(raw);
|
||||||
|
} catch (e) {
|
||||||
|
return {
|
||||||
|
ai_conversations: [],
|
||||||
|
ai_summaries: [],
|
||||||
|
ai_facts: [],
|
||||||
|
ai_knowledge: [],
|
||||||
|
ai_sessions: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
db.prepare(
|
function save(state) {
|
||||||
`
|
try {
|
||||||
CREATE TABLE IF NOT EXISTS ai_conversations (
|
fs.mkdirSync(path.dirname(DB_PATH), { recursive: true });
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
fs.writeFileSync(DB_PATH, JSON.stringify(state, null, 2), 'utf8');
|
||||||
guild_id TEXT,
|
} catch (e) {
|
||||||
channel_id TEXT,
|
console.error('Failed to persist ai-memory:', e);
|
||||||
user_id TEXT,
|
}
|
||||||
role TEXT NOT NULL,
|
}
|
||||||
content TEXT NOT NULL,
|
|
||||||
created_at INTEGER NOT NULL
|
|
||||||
)
|
|
||||||
`,
|
|
||||||
).run();
|
|
||||||
|
|
||||||
db.prepare(
|
const state = load();
|
||||||
`
|
|
||||||
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(
|
function nextId(arr) {
|
||||||
`
|
return arr.length === 0 ? 1 : Math.max(...arr.map(a => a.id || 0)) + 1;
|
||||||
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 = {
|
module.exports = {
|
||||||
saveUserFact({ guildId, userId, factType, factKey, factValue }) {
|
saveUserFact({ guildId, userId, factType, factKey, factValue }) {
|
||||||
const existing = db
|
const existing = state.ai_facts.find(f => f.guild_id === (guildId || null) && f.user_id === (userId || null) && f.fact_type === factType && f.fact_key === factKey);
|
||||||
.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) {
|
if (existing) {
|
||||||
db.prepare(
|
existing.fact_value = factValue;
|
||||||
`
|
existing.updated_at = Date.now();
|
||||||
UPDATE ai_facts
|
save(state);
|
||||||
SET fact_value = ?, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`,
|
|
||||||
).run(factValue, Date.now(), existing.id);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
db.prepare(
|
const id = nextId(state.ai_facts);
|
||||||
`
|
state.ai_facts.push({ id, guild_id: guildId || null, channel_id: null, user_id: userId || null, fact_type: factType, fact_key: factKey, fact_value: factValue, created_at: Date.now(), updated_at: Date.now() });
|
||||||
INSERT INTO ai_facts (
|
save(state);
|
||||||
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) {
|
getUserFacts(guildId, userId) {
|
||||||
return db
|
return state.ai_facts.filter(f => f.guild_id === (guildId || null) && f.user_id === (userId || null)).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_facts
|
|
||||||
WHERE guild_id IS ? AND user_id IS ?
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(guildId || null, userId || null);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getUserFactsByScope({ guildId = null, userId = null, scope = "guild" }) {
|
getUserFactsByScope({ guildId = null, userId = null, scope = "guild" }) {
|
||||||
if (!userId) return [];
|
if (!userId) return [];
|
||||||
|
|
||||||
if (scope === "global") {
|
if (scope === "global") {
|
||||||
return db
|
return state.ai_facts.filter(f => f.user_id === (userId || null)).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_facts
|
|
||||||
WHERE user_id IS ?
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(userId || null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return state.ai_facts.filter(f => f.guild_id === (guildId || null) && f.user_id === (userId || null)).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.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 }) {
|
addMessage({ guildId, channelId, userId, role, content }) {
|
||||||
db.prepare(
|
const id = nextId(state.ai_conversations);
|
||||||
`
|
state.ai_conversations.push({ id, guild_id: guildId || null, channel_id: channelId || null, user_id: userId || null, role, content, created_at: Date.now() });
|
||||||
INSERT INTO ai_conversations (
|
save(state);
|
||||||
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) {
|
getRecentMessages(channelId, limit = 15) {
|
||||||
return db
|
const msgs = state.ai_conversations.filter(m => m.channel_id === channelId).sort((a,b) => b.created_at - a.created_at).slice(0, limit).reverse();
|
||||||
.prepare(
|
return msgs;
|
||||||
`
|
|
||||||
SELECT * FROM ai_conversations
|
|
||||||
WHERE channel_id = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(channelId, limit)
|
|
||||||
.reverse();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getRecentMessagesByScope({ guildId = null, channelId = null, limit = 15, scope = "guild" }) {
|
getRecentMessagesByScope({ guildId = null, channelId = null, limit = 15, scope = "guild" }) {
|
||||||
if (scope === "channel") {
|
if (scope === "channel") {
|
||||||
return db
|
return state.ai_conversations.filter(m => m.channel_id === (channelId || null)).sort((a,b) => b.created_at - a.created_at).slice(0, limit).reverse();
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_conversations
|
|
||||||
WHERE channel_id = ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(channelId || null, limit)
|
|
||||||
.reverse();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "global") {
|
if (scope === "global") {
|
||||||
return db
|
return state.ai_conversations.sort((a,b) => b.created_at - a.created_at).slice(0, limit).reverse();
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_conversations
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(limit)
|
|
||||||
.reverse();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return state.ai_conversations.filter(m => m.guild_id === (guildId || null)).sort((a,b) => b.created_at - a.created_at).slice(0, limit).reverse();
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_conversations
|
|
||||||
WHERE guild_id IS ?
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(guildId || null, limit)
|
|
||||||
.reverse();
|
|
||||||
},
|
},
|
||||||
|
|
||||||
saveSummary({ guildId, channelId, summary }) {
|
saveSummary({ guildId, channelId, summary }) {
|
||||||
const existing = db
|
const existing = state.ai_summaries.find(s => s.guild_id === (guildId || null) && s.channel_id === (channelId || null));
|
||||||
.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) {
|
if (existing) {
|
||||||
db.prepare(
|
existing.summary = summary;
|
||||||
`
|
existing.updated_at = Date.now();
|
||||||
UPDATE ai_summaries
|
save(state);
|
||||||
SET summary = ?, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`,
|
|
||||||
).run(summary, Date.now(), existing.id);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const id = nextId(state.ai_summaries);
|
||||||
db.prepare(
|
state.ai_summaries.push({ id, guild_id: guildId || null, channel_id: channelId || null, summary, created_at: Date.now(), updated_at: Date.now() });
|
||||||
`
|
save(state);
|
||||||
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) {
|
getLatestSummary(channelId) {
|
||||||
return db
|
const arr = state.ai_summaries.filter(s => s.channel_id === channelId).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
return arr.length ? arr[0] : null;
|
||||||
`
|
|
||||||
SELECT * FROM ai_summaries
|
|
||||||
WHERE channel_id = ?
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.get(channelId);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getLatestSummaryByScope({ guildId = null, channelId = null, scope = "guild" }) {
|
getLatestSummaryByScope({ guildId = null, channelId = null, scope = "guild" }) {
|
||||||
if (scope === "channel") {
|
if (scope === "channel") {
|
||||||
return db
|
const arr = state.ai_summaries.filter(s => s.channel_id === (channelId || null)).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
return arr.length ? arr[0] : null;
|
||||||
`
|
|
||||||
SELECT * FROM ai_summaries
|
|
||||||
WHERE channel_id = ?
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.get(channelId || null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "global") {
|
if (scope === "global") {
|
||||||
return db
|
const arr = state.ai_summaries.sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
return arr.length ? arr[0] : null;
|
||||||
`
|
|
||||||
SELECT * FROM ai_summaries
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.get();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
const arr = state.ai_summaries.filter(s => s.guild_id === (guildId || null)).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
return arr.length ? arr[0] : null;
|
||||||
`
|
|
||||||
SELECT * FROM ai_summaries
|
|
||||||
WHERE guild_id IS ?
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT 1
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.get(guildId || null);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
saveFact({ guildId, channelId, factType, factKey, factValue }) {
|
saveFact({ guildId, channelId, factType, factKey, factValue }) {
|
||||||
const existing = db
|
const existing = state.ai_facts.find(f => f.guild_id === (guildId || null) && f.channel_id === (channelId || null) && f.fact_type === factType && f.fact_key === factKey);
|
||||||
.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) {
|
if (existing) {
|
||||||
db.prepare(
|
existing.fact_value = factValue;
|
||||||
`
|
existing.updated_at = Date.now();
|
||||||
UPDATE ai_facts
|
save(state);
|
||||||
SET fact_value = ?, updated_at = ?
|
|
||||||
WHERE id = ?
|
|
||||||
`,
|
|
||||||
).run(factValue, Date.now(), existing.id);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
const id = nextId(state.ai_facts);
|
||||||
db.prepare(
|
state.ai_facts.push({ id, guild_id: guildId || null, channel_id: channelId || null, user_id: null, fact_type: factType, fact_key: factKey, fact_value: factValue, created_at: Date.now(), updated_at: Date.now() });
|
||||||
`
|
save(state);
|
||||||
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) {
|
getFacts(channelId) {
|
||||||
return db
|
return state.ai_facts.filter(f => f.channel_id === channelId).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_facts
|
|
||||||
WHERE channel_id = ?
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(channelId);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getFactsByScope({ guildId = null, channelId = null, scope = "guild" }) {
|
getFactsByScope({ guildId = null, channelId = null, scope = "guild" }) {
|
||||||
if (scope === "channel") {
|
if (scope === "channel") {
|
||||||
return db
|
return state.ai_facts.filter(f => f.channel_id === (channelId || null) && f.user_id == null).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_facts
|
|
||||||
WHERE channel_id = ? AND user_id IS NULL
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(channelId || null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (scope === "global") {
|
if (scope === "global") {
|
||||||
return db
|
return state.ai_facts.filter(f => f.user_id == null).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_facts
|
|
||||||
WHERE user_id IS NULL
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return db
|
return state.ai_facts.filter(f => f.guild_id === (guildId || null) && f.user_id == null).sort((a,b) => b.updated_at - a.updated_at);
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_facts
|
|
||||||
WHERE guild_id IS ? AND user_id IS NULL
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(guildId || null);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
addKnowledge({ sourceType, sourceName, title, content }) {
|
addKnowledge({ sourceType, sourceName, title, content }) {
|
||||||
db.prepare(
|
const id = nextId(state.ai_knowledge);
|
||||||
`
|
state.ai_knowledge.push({ id, source_type: sourceType, source_name: sourceName, title, content, created_at: Date.now(), updated_at: Date.now() });
|
||||||
INSERT INTO ai_knowledge (
|
save(state);
|
||||||
source_type, source_name, title, content, created_at, updated_at
|
|
||||||
) VALUES (?, ?, ?, ?, ?, ?)
|
|
||||||
`,
|
|
||||||
).run(sourceType, sourceName, title, content, Date.now(), Date.now());
|
|
||||||
},
|
},
|
||||||
|
|
||||||
getKnowledge(limit = 25) {
|
getKnowledge(limit = 25) {
|
||||||
return db
|
return state.ai_knowledge.sort((a,b) => b.updated_at - a.updated_at).slice(0, limit);
|
||||||
.prepare(
|
|
||||||
`
|
|
||||||
SELECT * FROM ai_knowledge
|
|
||||||
ORDER BY updated_at DESC
|
|
||||||
LIMIT ?
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.all(limit);
|
|
||||||
},
|
},
|
||||||
|
|
||||||
clearKnowledge() {
|
clearKnowledge() {
|
||||||
db.prepare(`DELETE FROM ai_knowledge`).run();
|
state.ai_knowledge = [];
|
||||||
|
save(state);
|
||||||
},
|
},
|
||||||
|
|
||||||
clearConversations(channelId) {
|
clearConversations(channelId) {
|
||||||
if (!channelId) return;
|
if (!channelId) return;
|
||||||
db.prepare(`DELETE FROM ai_conversations WHERE channel_id = ?`).run(channelId);
|
state.ai_conversations = state.ai_conversations.filter(c => c.channel_id !== channelId);
|
||||||
|
save(state);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,185 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const axios = require("axios");
|
||||||
|
const { withTimeout } = require("./asyncTimeout.js");
|
||||||
|
const AdmZip = require("adm-zip");
|
||||||
|
const yaml = require("js-yaml");
|
||||||
|
|
||||||
|
const EMERALD_API = "https://emeraldsrv.dev/api/v1";
|
||||||
|
|
||||||
|
function getApiKey() {
|
||||||
|
try {
|
||||||
|
if (fs.existsSync("./Configs/api.yml")) {
|
||||||
|
const config = yaml.load(fs.readFileSync("./Configs/api.yml", "utf8"));
|
||||||
|
return config?.API?.EmeraldAPIKey || "";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to read api.yml:", e);
|
||||||
|
}
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureAddonDirs() {
|
||||||
|
if (!fs.existsSync("./Addons")) {
|
||||||
|
fs.mkdirSync("./Addons", { recursive: true });
|
||||||
|
}
|
||||||
|
if (!fs.existsSync("./Addons/Configs")) {
|
||||||
|
fs.mkdirSync("./Addons/Configs", { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listCatalogAddons() {
|
||||||
|
const key = getApiKey();
|
||||||
|
if (!key) throw new Error("EmeraldAPIKey not configured in api.yml");
|
||||||
|
|
||||||
|
const res = await withTimeout(
|
||||||
|
axios.get(`${EMERALD_API}/resources`, {
|
||||||
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
|
timeout: 15_000,
|
||||||
|
}),
|
||||||
|
18_000,
|
||||||
|
"Emerald API request timed out",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (res.data.status !== "success") {
|
||||||
|
throw new Error(res.data.message || "Failed to fetch catalog");
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.data.data.map(item => ({
|
||||||
|
id: item.id.toString(),
|
||||||
|
name: item.title,
|
||||||
|
slug: item.slug,
|
||||||
|
description: `Emerald Services Resource - ${item.slug}`,
|
||||||
|
price: item.price,
|
||||||
|
is_external: item.is_external,
|
||||||
|
external_store: item.external_store,
|
||||||
|
repositoryUrl: item.external_url || `https://emeraldsrv.dev`,
|
||||||
|
jsFiles: [],
|
||||||
|
configFiles: []
|
||||||
|
})).sort((a, b) => a.name.localeCompare(b.name));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadFile(id) {
|
||||||
|
const key = getApiKey();
|
||||||
|
const res = await withTimeout(
|
||||||
|
axios.get(`${EMERALD_API}/download?id=${id}`, {
|
||||||
|
headers: { Authorization: `Bearer ${key}` },
|
||||||
|
responseType: "arraybuffer",
|
||||||
|
timeout: 30_000
|
||||||
|
}),
|
||||||
|
35_000,
|
||||||
|
"Addon file download timed out",
|
||||||
|
);
|
||||||
|
return Buffer.from(res.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLocalAddonJs() {
|
||||||
|
ensureAddonDirs();
|
||||||
|
return fs
|
||||||
|
.readdirSync("./Addons")
|
||||||
|
.filter((f) => f.endsWith(".js") && f !== "addon.js");
|
||||||
|
}
|
||||||
|
|
||||||
|
function listLocalAddonConfigs() {
|
||||||
|
ensureAddonDirs();
|
||||||
|
const dir = "./Addons/Configs";
|
||||||
|
if (!fs.existsSync(dir)) return [];
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith(".yml") || f.endsWith(".yaml") || f.endsWith(".json"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAddonInstalled(catalogEntry) {
|
||||||
|
const localJs = listLocalAddonJs();
|
||||||
|
return localJs.some(f => f.includes(catalogEntry.slug) || f.includes(catalogEntry.id.toString()) || f.includes(catalogEntry.name.replace(/ /g, '')));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function listInstalledAddons(catalog) {
|
||||||
|
const localJs = listLocalAddonJs();
|
||||||
|
const installed = [];
|
||||||
|
|
||||||
|
for (const file of localJs) {
|
||||||
|
const name = file.replace(/\.js$/, "");
|
||||||
|
const match = catalog.find(c => c.slug === name || c.id.toString() === name || c.name.replace(/ /g, '') === name);
|
||||||
|
|
||||||
|
installed.push({
|
||||||
|
id: match ? match.id : name,
|
||||||
|
name: match ? match.name : name,
|
||||||
|
jsFiles: [file],
|
||||||
|
configFiles: listLocalAddonConfigs().filter(f => f.startsWith(name)),
|
||||||
|
custom: !match
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return installed;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function installAddonFromCatalog(addonId) {
|
||||||
|
const catalog = await listCatalogAddons();
|
||||||
|
const addon = catalog.find(c => c.id === addonId.toString());
|
||||||
|
|
||||||
|
if (!addon) {
|
||||||
|
throw new Error(`Addon not found in catalog`);
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAddonDirs();
|
||||||
|
const content = await downloadFile(addon.id);
|
||||||
|
|
||||||
|
const zip = new AdmZip(content);
|
||||||
|
zip.extractAllTo("./Addons", true);
|
||||||
|
|
||||||
|
return {
|
||||||
|
addon: addon,
|
||||||
|
filesWritten: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readAddonConfig(filename) {
|
||||||
|
const safe = path.basename(filename);
|
||||||
|
if (!/^[a-zA-Z0-9_.-]+\.(yml|yaml|json)$/.test(safe)) {
|
||||||
|
throw new Error("Invalid config filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join("./Addons/Configs", safe);
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
throw new Error("Addon config not found");
|
||||||
|
}
|
||||||
|
|
||||||
|
const raw = fs.readFileSync(filePath, "utf8");
|
||||||
|
if (safe.endsWith(".json")) {
|
||||||
|
return { filename: safe, format: "json", data: JSON.parse(raw), raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
const yaml = require("js-yaml");
|
||||||
|
return { filename: safe, format: "yaml", data: yaml.load(raw) || {}, raw };
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeAddonConfig(filename, data) {
|
||||||
|
const safe = path.basename(filename);
|
||||||
|
if (!/^[a-zA-Z0-9_.-]+\.(yml|yaml|json)$/.test(safe)) {
|
||||||
|
throw new Error("Invalid config filename");
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureAddonDirs();
|
||||||
|
const filePath = path.join("./Addons/Configs", safe);
|
||||||
|
let content;
|
||||||
|
|
||||||
|
if (safe.endsWith(".json")) {
|
||||||
|
content = `${JSON.stringify(data, null, 2)}\n`;
|
||||||
|
} else {
|
||||||
|
const yaml = require("js-yaml");
|
||||||
|
content = yaml.dump(data, { lineWidth: 120, noRefs: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(filePath, content);
|
||||||
|
return { filename: safe, path: filePath.replace(/\\/g, "/") };
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listCatalogAddons,
|
||||||
|
listInstalledAddons,
|
||||||
|
isAddonInstalled,
|
||||||
|
installAddonFromCatalog,
|
||||||
|
listLocalAddonConfigs,
|
||||||
|
readAddonConfig,
|
||||||
|
writeAddonConfig,
|
||||||
|
};
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
function getBotInviteUrl(clientId) {
|
||||||
|
if (!clientId) return null;
|
||||||
|
return `https://discord.com/api/oauth2/authorize?client_id=${clientId}&permissions=8&scope=bot%20applications.commands`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { getBotInviteUrl };
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const configStore = require("./ConfigStore.js");
|
||||||
|
const { getPrimaryGuild } = require("./GuildManager.js");
|
||||||
|
const {
|
||||||
|
applyPresence,
|
||||||
|
runBotReady,
|
||||||
|
logSlashCommandsRegistered,
|
||||||
|
} = require("./BotStartup.js");
|
||||||
|
|
||||||
|
function walk(dir, ext = ".js", fileList = []) {
|
||||||
|
if (!fs.existsSync(dir)) return fileList;
|
||||||
|
for (const file of fs.readdirSync(dir)) {
|
||||||
|
const full = path.join(dir, file);
|
||||||
|
if (fs.statSync(full).isDirectory()) {
|
||||||
|
walk(full, ext, fileList);
|
||||||
|
} else if (file.endsWith(ext)) {
|
||||||
|
fileList.push(full.replace(/\\/g, "/"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fileList;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSlashCommands(client) {
|
||||||
|
if (!client?.application) return { count: 0 };
|
||||||
|
|
||||||
|
const cmdconfig = configStore.commands;
|
||||||
|
const commandFiles = walk("./Commands");
|
||||||
|
client.commands.clear();
|
||||||
|
|
||||||
|
for (const file of commandFiles) {
|
||||||
|
const resolved = path.resolve(file);
|
||||||
|
if (require.cache[resolved]) {
|
||||||
|
delete require.cache[resolved];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = 0;
|
||||||
|
for (const file of commandFiles) {
|
||||||
|
const command = require(path.resolve(file));
|
||||||
|
const matchingConfig = Object.values(cmdconfig).find(
|
||||||
|
(entry) =>
|
||||||
|
entry &&
|
||||||
|
typeof entry === "object" &&
|
||||||
|
entry.Command &&
|
||||||
|
String(entry.Command).toLowerCase() ===
|
||||||
|
String(command.name).toLowerCase(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (matchingConfig && matchingConfig.Enabled === false) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.commands.set(command.name, command);
|
||||||
|
count++;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = client.commands.map((cmd) => cmd.data ?? cmd);
|
||||||
|
for (const guild of client.guilds.cache.values()) {
|
||||||
|
await guild.commands.set(payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { count };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadBot(client, options = {}) {
|
||||||
|
const { fileKey, refreshCommands = true } = options;
|
||||||
|
|
||||||
|
let reloaded;
|
||||||
|
if (fileKey) {
|
||||||
|
configStore.reloadFile(fileKey);
|
||||||
|
reloaded = [fileKey];
|
||||||
|
} else {
|
||||||
|
reloaded = configStore.reloadBotConfigs();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyPresence(client);
|
||||||
|
|
||||||
|
let commands = null;
|
||||||
|
if (refreshCommands && (!fileKey || fileKey === "commands")) {
|
||||||
|
commands = await refreshSlashCommands(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
reloaded,
|
||||||
|
commands,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload configs from disk, reconnect the Discord session, and refresh presence/commands.
|
||||||
|
* API and dashboard keep running on the same process.
|
||||||
|
*/
|
||||||
|
async function restartDiscordBot(client) {
|
||||||
|
if (!client) {
|
||||||
|
const err = new Error("Bot client not available");
|
||||||
|
err.code = "NO_CLIENT";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const reloaded = configStore.reloadBotConfigs();
|
||||||
|
const token = configStore.supportbot?.General?.Token;
|
||||||
|
|
||||||
|
if (!token || String(token).trim() === "" || token === "BOT_TOKEN") {
|
||||||
|
const err = new Error("Invalid or missing bot token in supportbot.yml");
|
||||||
|
err.code = "INVALID_TOKEN";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("[BotReload] Restarting Discord bot after config change…");
|
||||||
|
client.__restarting = true;
|
||||||
|
|
||||||
|
if (client.user) {
|
||||||
|
try {
|
||||||
|
await client.destroy();
|
||||||
|
} catch (destroyErr) {
|
||||||
|
console.warn("[BotReload] destroy:", destroyErr.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
client.__explicitStartup = true;
|
||||||
|
try {
|
||||||
|
try {
|
||||||
|
await client.login(token);
|
||||||
|
} catch (loginErr) {
|
||||||
|
console.error("[BotReload] login failed:", loginErr.message);
|
||||||
|
const err = new Error(`Could not start bot: ${loginErr.message}`);
|
||||||
|
err.code = "LOGIN_FAILED";
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// clientReady does not fire again after destroy()+login() — run startup explicitly
|
||||||
|
await runBotReady(client, { clearConsole: true, logCommands: false });
|
||||||
|
const commands = await refreshSlashCommands(client);
|
||||||
|
logSlashCommandsRegistered(client);
|
||||||
|
|
||||||
|
console.log("[BotReload] Bot restarted successfully");
|
||||||
|
return { restarted: true, reloaded, commands };
|
||||||
|
} finally {
|
||||||
|
client.__explicitStartup = false;
|
||||||
|
client.__restarting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
applyPresence,
|
||||||
|
refreshSlashCommands,
|
||||||
|
reloadBot,
|
||||||
|
restartDiscordBot,
|
||||||
|
};
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
const Discord = require("discord.js");
|
||||||
|
const configStore = require("./ConfigStore.js");
|
||||||
|
const {
|
||||||
|
getPrimaryGuild,
|
||||||
|
syncGuildHealth,
|
||||||
|
} = require("./GuildManager.js");
|
||||||
|
|
||||||
|
const ACTIVITY_TYPES = {
|
||||||
|
PLAYING: Discord.ActivityType.Playing,
|
||||||
|
WATCHING: Discord.ActivityType.Watching,
|
||||||
|
LISTENING: Discord.ActivityType.Listening,
|
||||||
|
COMPETING: Discord.ActivityType.Competing,
|
||||||
|
STREAMING: Discord.ActivityType.Streaming,
|
||||||
|
};
|
||||||
|
|
||||||
|
function applyPresence(client) {
|
||||||
|
if (!client?.user) return;
|
||||||
|
|
||||||
|
const activity = configStore.supportbot?.Activity;
|
||||||
|
const typeKey = activity?.Type?.toUpperCase();
|
||||||
|
const type = ACTIVITY_TYPES[typeKey] ?? Discord.ActivityType.Playing;
|
||||||
|
const name = activity?.Status || "Online";
|
||||||
|
|
||||||
|
const activityPayload = { name, type };
|
||||||
|
|
||||||
|
if (typeKey === "STREAMING" && activity?.StreamingURL) {
|
||||||
|
activityPayload.url = activity.StreamingURL;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.user.setPresence({
|
||||||
|
activities: [activityPayload],
|
||||||
|
status: "online",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const c = {
|
||||||
|
reset: "\x1b[0m",
|
||||||
|
gray: "\x1b[90m",
|
||||||
|
green: "\x1b[32m",
|
||||||
|
yellow: "\x1b[33m",
|
||||||
|
bold: "\x1b[1m",
|
||||||
|
white: "\x1b[37m",
|
||||||
|
};
|
||||||
|
|
||||||
|
function line() {
|
||||||
|
console.log(
|
||||||
|
`${c.gray}────────────────────────────────────────────────────────${c.reset}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function section(title) {
|
||||||
|
console.log(`\n${c.green}${title}${c.reset}`);
|
||||||
|
console.log(`${c.gray}${"-".repeat(title.length)}${c.reset}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function info(label, value) {
|
||||||
|
console.log(`${c.gray}${label.padEnd(14)}${c.reset} ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function status(label, ok, details = "") {
|
||||||
|
const icon = ok ? `${c.green}✓${c.reset}` : `${c.yellow}!${c.reset}`;
|
||||||
|
console.log(` ${icon} ${label}${details ? ` (${details})` : ""}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function logStartup(client, options = {}) {
|
||||||
|
const { clearConsole = true } = options;
|
||||||
|
const supportbot = configStore.supportbot;
|
||||||
|
|
||||||
|
if (clearConsole) {
|
||||||
|
console.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
const botName = supportbot?.General?.Name || "SupportBot";
|
||||||
|
const botVersion = supportbot?.SupportBot_Version || "Unknown";
|
||||||
|
const { getBotInviteUrl } = require("./BotInvite.js");
|
||||||
|
const inviteUrl = getBotInviteUrl(client.user.id);
|
||||||
|
|
||||||
|
line();
|
||||||
|
console.log(`${c.bold}${c.green}SupportBot${c.reset}`);
|
||||||
|
console.log(`${c.gray}Emerald Services${c.reset}`);
|
||||||
|
line();
|
||||||
|
|
||||||
|
section("System");
|
||||||
|
info("Bot Name", botName);
|
||||||
|
info("Version", botVersion);
|
||||||
|
info("Node.js", process.version);
|
||||||
|
info("Discord.js", `v${Discord.version}`);
|
||||||
|
const db = require("./Database.js");
|
||||||
|
const dbInfo = db.getDatabaseInfo ? db.getDatabaseInfo() : null;
|
||||||
|
if (dbInfo) {
|
||||||
|
const dbDisplay = dbInfo.type === "mysql"
|
||||||
|
? `${dbInfo.driver} (${dbInfo.host}:${dbInfo.port}/${dbInfo.database})`
|
||||||
|
: `${dbInfo.driver} (${dbInfo.file})`;
|
||||||
|
info("Database", dbDisplay);
|
||||||
|
}
|
||||||
|
info("Logged in as", client.user.tag);
|
||||||
|
info("Guilds", `${client.guilds.cache.size}`);
|
||||||
|
|
||||||
|
section("Links");
|
||||||
|
info("Docs", "https://emerald-services.gitbook.io/");
|
||||||
|
info("Discord", "https://dsc.gg/emerald-dev");
|
||||||
|
|
||||||
|
if (configStore.api?.API?.Enabled) {
|
||||||
|
const port = configStore.api?.API?.Port || 3000;
|
||||||
|
info("Dashboard", `http://localhost:${port}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
info("Invite", inviteUrl);
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function checkSetup() {
|
||||||
|
const { isSetupComplete } = require("./DashboardSetup.js");
|
||||||
|
if (!isSetupComplete()) {
|
||||||
|
const port = configStore.api?.API?.Port || 25575;
|
||||||
|
section("Setup Required");
|
||||||
|
status("Onboarding setup incomplete", false, `http://localhost:${port}/setup`);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function checkConfigs(client) {
|
||||||
|
const supportbot = configStore.supportbot;
|
||||||
|
const health = syncGuildHealth(client);
|
||||||
|
|
||||||
|
if (health.status !== "ok") {
|
||||||
|
section("Guild");
|
||||||
|
status("Guild setup", false, health.message || health.status);
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const guild = getPrimaryGuild(client);
|
||||||
|
if (!guild) return;
|
||||||
|
|
||||||
|
section("Config Check");
|
||||||
|
|
||||||
|
const roles = [
|
||||||
|
supportbot?.Roles?.StaffMember?.Admin,
|
||||||
|
supportbot?.Roles?.StaffMember?.Staff,
|
||||||
|
supportbot?.Roles?.AutoRole?.Role,
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const channels = [supportbot?.Ticket?.TicketHome].filter(Boolean);
|
||||||
|
|
||||||
|
const [roleResults, channelResults] = await Promise.all([
|
||||||
|
Promise.all(roles.map((role) => client.getRole(role, guild))),
|
||||||
|
Promise.all(channels.map((channel) => client.getChannel(channel, guild))),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const missingRoles = roles.filter((_, i) => !roleResults[i]);
|
||||||
|
const missingChannels = channels.filter((_, i) => !channelResults[i]);
|
||||||
|
|
||||||
|
if (!missingRoles.length && !missingChannels.length) {
|
||||||
|
status("All configurations validated successfully", true);
|
||||||
|
} else {
|
||||||
|
if (missingRoles.length) {
|
||||||
|
status("Missing roles", false, missingRoles.join(", "));
|
||||||
|
}
|
||||||
|
if (missingChannels.length) {
|
||||||
|
status("Missing channels", false, missingChannels.join(", "));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function logSlashCommandsRegistered(client) {
|
||||||
|
const guild = getPrimaryGuild(client);
|
||||||
|
if (!guild) return;
|
||||||
|
console.log(
|
||||||
|
`\n${c.green}✓${c.reset} ${c.white}Slash Commands Registered${c.reset} ${c.gray}for ${guild.name}${c.reset}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Same work as Events/ready.js — safe to call after config reload reconnects.
|
||||||
|
*/
|
||||||
|
async function runBotReady(client, options = {}) {
|
||||||
|
const { clearConsole = true, logCommands = false } = options;
|
||||||
|
|
||||||
|
syncGuildHealth(client);
|
||||||
|
applyPresence(client);
|
||||||
|
await logStartup(client, { clearConsole });
|
||||||
|
await checkConfigs(client);
|
||||||
|
|
||||||
|
if (logCommands) {
|
||||||
|
logSlashCommandsRegistered(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
checkSetup();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
applyPresence,
|
||||||
|
logStartup,
|
||||||
|
checkConfigs,
|
||||||
|
logSlashCommandsRegistered,
|
||||||
|
runBotReady,
|
||||||
|
};
|
||||||
+17
-12
@@ -2,11 +2,12 @@ const fs = require("fs");
|
|||||||
const path = require("path");
|
const path = require("path");
|
||||||
const Discord = require("discord.js");
|
const Discord = require("discord.js");
|
||||||
const { GatewayIntentBits, Partials } = require("discord.js");
|
const { GatewayIntentBits, Partials } = require("discord.js");
|
||||||
const yaml = require("js-yaml");
|
|
||||||
const { Command, Event } = require("./Addon.js");
|
const { Command, Event } = require("./Addon.js");
|
||||||
|
const configStore = require("./ConfigStore.js");
|
||||||
|
const { getPrimaryGuild } = require("./GuildManager.js");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = configStore.supportbot;
|
||||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
const cmdconfig = configStore.commands;
|
||||||
|
|
||||||
const c = {
|
const c = {
|
||||||
reset: "\x1b[0m",
|
reset: "\x1b[0m",
|
||||||
@@ -50,6 +51,7 @@ class Client extends Discord.Client {
|
|||||||
GatewayIntentBits.GuildMembers,
|
GatewayIntentBits.GuildMembers,
|
||||||
GatewayIntentBits.GuildMessages,
|
GatewayIntentBits.GuildMessages,
|
||||||
GatewayIntentBits.GuildMessageReactions,
|
GatewayIntentBits.GuildMessageReactions,
|
||||||
|
GatewayIntentBits.GuildEmojisAndStickers,
|
||||||
GatewayIntentBits.MessageContent,
|
GatewayIntentBits.MessageContent,
|
||||||
],
|
],
|
||||||
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
||||||
@@ -60,26 +62,29 @@ class Client extends Discord.Client {
|
|||||||
|
|
||||||
async getChannel(channel, guild) {
|
async getChannel(channel, guild) {
|
||||||
if (!channel) return null;
|
if (!channel) return null;
|
||||||
|
const channelStr = String(channel).toLowerCase();
|
||||||
return guild.channels.cache.find(
|
return guild.channels.cache.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
(c.type === Discord.ChannelType.GuildText || c.type === Discord.ChannelType.GuildNews) &&
|
(c.type === Discord.ChannelType.GuildText || c.type === Discord.ChannelType.GuildNews) &&
|
||||||
(c.id === channel || (c.name && c.name.toLowerCase() === channel.toLowerCase())),
|
(c.id === channel || (c.name && c.name.toLowerCase() === channelStr)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getRole(role, guild) {
|
async getRole(role, guild) {
|
||||||
if (!role) return null;
|
if (!role) return null;
|
||||||
|
const roleStr = String(role).toLowerCase();
|
||||||
return guild.roles.cache.find(
|
return guild.roles.cache.find(
|
||||||
(r) => r.id === role || (r.name && r.name.toLowerCase() === role.toLowerCase()),
|
(r) => r.id === role || (r.name && r.name.toLowerCase() === roleStr),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getCategory(category, guild) {
|
async getCategory(category, guild) {
|
||||||
if (!category) return null;
|
if (!category) return null;
|
||||||
|
const catStr = String(category).toLowerCase();
|
||||||
return guild.channels.cache.find(
|
return guild.channels.cache.find(
|
||||||
(c) =>
|
(c) =>
|
||||||
c.type === Discord.ChannelType.GuildCategory &&
|
c.type === Discord.ChannelType.GuildCategory &&
|
||||||
(c.id === category || (c.name && c.name.toLowerCase() === category.toLowerCase())),
|
(c.id === category || (c.name && c.name.toLowerCase() === catStr)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -155,12 +160,12 @@ class Client extends Discord.Client {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
this.once("clientReady", async () => {
|
this.on("clientReady", async () => {
|
||||||
await this.guilds.cache.first()?.commands.set(this.commands);
|
const guild = getPrimaryGuild(this);
|
||||||
|
if (!guild) return;
|
||||||
console.log(
|
await guild.commands.set(this.commands);
|
||||||
`\n${c.green}✓${c.reset} ${c.white}Slash Commands Registered${c.reset} ${c.gray}for ${this.guilds.cache.first().name}${c.reset}`,
|
const { logSlashCommandsRegistered } = require("./BotStartup.js");
|
||||||
);
|
logSlashCommandsRegistered(this);
|
||||||
});
|
});
|
||||||
|
|
||||||
section("Events");
|
section("Events");
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// Live config objects — reload mutates in place so existing references stay valid.
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const yaml = require("js-yaml");
|
||||||
|
|
||||||
|
const CONFIG_DIR = path.join(__dirname, "../Configs");
|
||||||
|
|
||||||
|
const FILE_KEYS = [
|
||||||
|
"supportbot",
|
||||||
|
"commands",
|
||||||
|
"messages",
|
||||||
|
"ticket-panel",
|
||||||
|
"supportbot-ai",
|
||||||
|
"api",
|
||||||
|
];
|
||||||
|
|
||||||
|
function readConfig(name) {
|
||||||
|
return yaml.load(fs.readFileSync(path.join(CONFIG_DIR, `${name}.yml`), "utf8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepAssign(target, source) {
|
||||||
|
if (!target || typeof target !== "object" || Array.isArray(target)) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
if (!source || typeof source !== "object" || Array.isArray(source)) {
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(source)) {
|
||||||
|
const next = source[key];
|
||||||
|
if (
|
||||||
|
next &&
|
||||||
|
typeof next === "object" &&
|
||||||
|
!Array.isArray(next) &&
|
||||||
|
target[key] &&
|
||||||
|
typeof target[key] === "object" &&
|
||||||
|
!Array.isArray(target[key])
|
||||||
|
) {
|
||||||
|
deepAssign(target[key], next);
|
||||||
|
} else {
|
||||||
|
target[key] = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key of Object.keys(target)) {
|
||||||
|
if (!(key in source)) {
|
||||||
|
delete target[key];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return target;
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = {};
|
||||||
|
for (const name of FILE_KEYS) {
|
||||||
|
store[name] = readConfig(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadFile(fileKey) {
|
||||||
|
if (!FILE_KEYS.includes(fileKey)) {
|
||||||
|
throw new Error(`Unknown config: ${fileKey}`);
|
||||||
|
}
|
||||||
|
const fresh = readConfig(fileKey);
|
||||||
|
deepAssign(store[fileKey], fresh);
|
||||||
|
return store[fileKey];
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadBotConfigs() {
|
||||||
|
const reloaded = [];
|
||||||
|
for (const name of FILE_KEYS) {
|
||||||
|
if (name === "api") continue;
|
||||||
|
reloadFile(name);
|
||||||
|
reloaded.push(name);
|
||||||
|
}
|
||||||
|
return reloaded;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
supportbot: store.supportbot,
|
||||||
|
commands: store.commands,
|
||||||
|
messages: store.messages,
|
||||||
|
ticketPanel: store["ticket-panel"],
|
||||||
|
supportbotAi: store["supportbot-ai"],
|
||||||
|
api: store.api,
|
||||||
|
store,
|
||||||
|
reloadFile,
|
||||||
|
reloadBotConfigs,
|
||||||
|
readConfig,
|
||||||
|
};
|
||||||
@@ -0,0 +1,229 @@
|
|||||||
|
/**
|
||||||
|
* Dashboard alert registry — server-side providers for actionable banners.
|
||||||
|
*
|
||||||
|
* Register a provider to surface new alerts anywhere in the dashboard:
|
||||||
|
*
|
||||||
|
* const { dashboardAlerts } = require("./DashboardAlerts.js");
|
||||||
|
* dashboardAlerts.register("my-addon", (ctx) => {
|
||||||
|
* if (!ctx.configStore.myAddon?.Configured) {
|
||||||
|
* return [{
|
||||||
|
* id: "my-addon:setup",
|
||||||
|
* severity: "warning",
|
||||||
|
* category: "my-addon",
|
||||||
|
* title: "My addon needs setup",
|
||||||
|
* message: "Finish configuration to enable this feature.",
|
||||||
|
* action: { label: "Configure", href: "/configs?file=my-addon" },
|
||||||
|
* }];
|
||||||
|
* }
|
||||||
|
* return [];
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
const { evaluateGuildHealth } = require("./GuildManager.js");
|
||||||
|
const { evaluateAllModules } = require("./ModuleStatus.js");
|
||||||
|
|
||||||
|
const SEVERITY_ORDER = { error: 0, warning: 1, info: 2 };
|
||||||
|
|
||||||
|
const MODULE_LINKS = {
|
||||||
|
tickets: {
|
||||||
|
title: "Ticket system",
|
||||||
|
href: "/configs?file=ticket-panel",
|
||||||
|
},
|
||||||
|
supportbot: {
|
||||||
|
title: "Core bot settings",
|
||||||
|
href: "/configs?file=supportbot",
|
||||||
|
},
|
||||||
|
transcripts: {
|
||||||
|
title: "Ticket transcripts",
|
||||||
|
href: "/transcripts",
|
||||||
|
},
|
||||||
|
suggestions: {
|
||||||
|
title: "Suggestions",
|
||||||
|
href: "/configs?file=messages",
|
||||||
|
},
|
||||||
|
ai: {
|
||||||
|
title: "AI assistant",
|
||||||
|
href: "/configs?file=supportbot-ai",
|
||||||
|
},
|
||||||
|
commands: {
|
||||||
|
title: "Commands",
|
||||||
|
href: "/configs?file=commands",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
class DashboardAlertRegistry {
|
||||||
|
constructor() {
|
||||||
|
/** @type {Map<string, (ctx: DashboardAlertContext) => DashboardAlert[] | Promise<DashboardAlert[]>>} */
|
||||||
|
this.providers = new Map();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string} id Unique provider id (used for debugging / overrides)
|
||||||
|
* @param {(ctx: DashboardAlertContext) => DashboardAlert[] | Promise<DashboardAlert[]>} fn
|
||||||
|
*/
|
||||||
|
register(id, fn) {
|
||||||
|
this.providers.set(id, fn);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister(id) {
|
||||||
|
this.providers.delete(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @param {DashboardAlertContext} ctx */
|
||||||
|
async evaluate(ctx) {
|
||||||
|
const alerts = [];
|
||||||
|
for (const fn of this.providers.values()) {
|
||||||
|
const result = await fn(ctx);
|
||||||
|
if (Array.isArray(result) && result.length) {
|
||||||
|
alerts.push(...result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sortAlerts(alerts);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortAlerts(alerts) {
|
||||||
|
return [...alerts].sort((a, b) => {
|
||||||
|
const sa = SEVERITY_ORDER[a.severity] ?? 9;
|
||||||
|
const sb = SEVERITY_ORDER[b.severity] ?? 9;
|
||||||
|
if (sa !== sb) return sa - sb;
|
||||||
|
return a.title.localeCompare(b.title);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function guildSeverity(status) {
|
||||||
|
if (status === "offline") return "error";
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
|
|
||||||
|
function shouldSkipModuleAlert(moduleId, info) {
|
||||||
|
if (info.status === "done") return true;
|
||||||
|
if (moduleId === "ai" && info.subtitle === "AI is disabled in config") return true;
|
||||||
|
// Transcripts are automatic — empty archive is normal, not a setup step.
|
||||||
|
if (moduleId === "transcripts") return true;
|
||||||
|
// Partially configured modules are tracked on Overview — only banner when nothing is done yet.
|
||||||
|
if (info.status === "progress" && (info.passed ?? 0) > 0) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function moduleSeverity(moduleId, info) {
|
||||||
|
if (info.status === "todo") {
|
||||||
|
return moduleId === "tickets" || moduleId === "supportbot" ? "warning" : "info";
|
||||||
|
}
|
||||||
|
return "info";
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateGuildAlerts(ctx) {
|
||||||
|
const { client } = ctx;
|
||||||
|
if (!client?.user) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "guild:offline",
|
||||||
|
severity: "error",
|
||||||
|
category: "guild",
|
||||||
|
title: "Bot offline",
|
||||||
|
message: "The bot is not connected to Discord. Check your token and restart the server.",
|
||||||
|
action: { label: "Bot config", href: "/configs?file=supportbot" },
|
||||||
|
meta: { status: "offline" },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const health = evaluateGuildHealth(client);
|
||||||
|
if (health.status === "ok") return [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "guild:health",
|
||||||
|
severity: guildSeverity(health.status),
|
||||||
|
category: "guild",
|
||||||
|
title: "Server setup required",
|
||||||
|
message: health.message,
|
||||||
|
action: { label: "Bot config", href: "/configs?file=supportbot" },
|
||||||
|
meta: {
|
||||||
|
status: health.status,
|
||||||
|
configuredGuildId: health.configuredGuildId,
|
||||||
|
configuredGuild: health.configuredGuild,
|
||||||
|
extraGuilds: health.extraGuilds,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateModuleAlerts(ctx) {
|
||||||
|
const { configStore, transcriptCount = 0 } = ctx;
|
||||||
|
const modules = evaluateAllModules(configStore, { transcriptCount });
|
||||||
|
const alerts = [];
|
||||||
|
|
||||||
|
for (const [moduleId, info] of Object.entries(modules)) {
|
||||||
|
if (shouldSkipModuleAlert(moduleId, info)) continue;
|
||||||
|
|
||||||
|
const link = MODULE_LINKS[moduleId] || {
|
||||||
|
title: moduleId,
|
||||||
|
href: "/configs",
|
||||||
|
};
|
||||||
|
|
||||||
|
const progress =
|
||||||
|
info.total > 0 ? `${info.passed ?? 0} of ${info.total} checks complete` : null;
|
||||||
|
|
||||||
|
alerts.push({
|
||||||
|
id: `module:${moduleId}`,
|
||||||
|
severity: moduleSeverity(moduleId, info),
|
||||||
|
category: "modules",
|
||||||
|
title:
|
||||||
|
info.status === "todo"
|
||||||
|
? `${link.title} — setup required`
|
||||||
|
: `${link.title} — in progress`,
|
||||||
|
message: info.subtitle,
|
||||||
|
action: { label: "Open module", href: link.href },
|
||||||
|
meta: {
|
||||||
|
moduleId,
|
||||||
|
status: info.status,
|
||||||
|
passed: info.passed,
|
||||||
|
total: info.total,
|
||||||
|
progress,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dashboardAlerts = new DashboardAlertRegistry();
|
||||||
|
dashboardAlerts.register("guild", evaluateGuildAlerts);
|
||||||
|
dashboardAlerts.register("modules", evaluateModuleAlerts);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DashboardAlertAction
|
||||||
|
* @property {string} label
|
||||||
|
* @property {string} href
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DashboardAlert
|
||||||
|
* @property {string} id
|
||||||
|
* @property {"error"|"warning"|"info"} severity
|
||||||
|
* @property {string} category
|
||||||
|
* @property {string} title
|
||||||
|
* @property {string} message
|
||||||
|
* @property {DashboardAlertAction} [action]
|
||||||
|
* @property {Record<string, unknown>} [meta]
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @typedef {Object} DashboardAlertContext
|
||||||
|
* @property {import("discord.js").Client} [client]
|
||||||
|
* @property {import("./ConfigStore.js")} configStore
|
||||||
|
* @property {number} [transcriptCount]
|
||||||
|
*/
|
||||||
|
|
||||||
|
async function evaluateDashboardAlerts(ctx) {
|
||||||
|
return dashboardAlerts.evaluate(ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
dashboardAlerts,
|
||||||
|
evaluateDashboardAlerts,
|
||||||
|
sortAlerts,
|
||||||
|
MODULE_LINKS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
const { fullPermissions, hasPermission, canAccessConfig } = require("./DashboardPermissions.js");
|
||||||
|
|
||||||
|
function grantServiceAccess(req) {
|
||||||
|
req.dashboardService = true;
|
||||||
|
req.dashboardPermissions = fullPermissions();
|
||||||
|
req.dashboardRole = "service";
|
||||||
|
req.dashboardIsOwner = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function forbid(res, message = "Forbidden") {
|
||||||
|
return res.status(403).json({ success: false, error: message });
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPermissionMiddleware(getPermissions) {
|
||||||
|
return function requirePermission(permission) {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (req.dashboardService) return next();
|
||||||
|
const perms = getPermissions(req) || req.dashboardPermissions;
|
||||||
|
if (hasPermission(perms, permission)) return next();
|
||||||
|
return forbid(res);
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createConfigMiddleware(getPermissions) {
|
||||||
|
return function requireConfigAccess(mode = "view") {
|
||||||
|
return (req, res, next) => {
|
||||||
|
if (req.dashboardService) return next();
|
||||||
|
const perms = getPermissions(req) || req.dashboardPermissions;
|
||||||
|
const file = req.params?.file || req.body?.filename;
|
||||||
|
if (!file) return next();
|
||||||
|
if (!canAccessConfig(perms, file, mode)) return forbid(res);
|
||||||
|
return next();
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function assertConfigFilesEditable(permissions, files, res) {
|
||||||
|
if (!permissions) return forbid(res);
|
||||||
|
for (const file of files) {
|
||||||
|
if (!canAccessConfig(permissions, file, "edit")) {
|
||||||
|
return forbid(res, `No permission to edit ${file}.yml`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
grantServiceAccess,
|
||||||
|
forbid,
|
||||||
|
createPermissionMiddleware,
|
||||||
|
createConfigMiddleware,
|
||||||
|
assertConfigFilesEditable,
|
||||||
|
fullPermissions,
|
||||||
|
hasPermission,
|
||||||
|
canAccessConfig,
|
||||||
|
};
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const BRANDING_DIR = path.join(__dirname, '../Data/branding');
|
||||||
|
const BRANDING_FILE = path.join(BRANDING_DIR, 'branding.json');
|
||||||
|
const FAVICON_BASENAME = 'favicon';
|
||||||
|
|
||||||
|
const DEFAULTS = {
|
||||||
|
title: 'SupportBot',
|
||||||
|
pageTitle: 'SupportBot Dashboard',
|
||||||
|
faviconFile: null,
|
||||||
|
faviconMime: null,
|
||||||
|
updatedAt: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ALLOWED_MIME = new Set([
|
||||||
|
'image/png',
|
||||||
|
'image/jpeg',
|
||||||
|
'image/x-icon',
|
||||||
|
'image/vnd.microsoft.icon',
|
||||||
|
'image/svg+xml',
|
||||||
|
'image/webp',
|
||||||
|
]);
|
||||||
|
|
||||||
|
const MIME_EXT = {
|
||||||
|
'image/png': '.png',
|
||||||
|
'image/jpeg': '.jpg',
|
||||||
|
'image/x-icon': '.ico',
|
||||||
|
'image/vnd.microsoft.icon': '.ico',
|
||||||
|
'image/svg+xml': '.svg',
|
||||||
|
'image/webp': '.webp',
|
||||||
|
};
|
||||||
|
|
||||||
|
function ensureDir() {
|
||||||
|
if (!fs.existsSync(BRANDING_DIR)) {
|
||||||
|
fs.mkdirSync(BRANDING_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readBranding() {
|
||||||
|
ensureDir();
|
||||||
|
if (!fs.existsSync(BRANDING_FILE)) {
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(BRANDING_FILE, 'utf8'));
|
||||||
|
return {
|
||||||
|
title: String(raw.title || DEFAULTS.title).slice(0, 64),
|
||||||
|
pageTitle: String(raw.pageTitle || DEFAULTS.pageTitle).slice(0, 80),
|
||||||
|
faviconFile: raw.faviconFile || null,
|
||||||
|
faviconMime: raw.faviconMime || null,
|
||||||
|
updatedAt: raw.updatedAt || null,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULTS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeBranding(data) {
|
||||||
|
ensureDir();
|
||||||
|
const next = {
|
||||||
|
title: String(data.title || DEFAULTS.title).slice(0, 64),
|
||||||
|
pageTitle: String(data.pageTitle || DEFAULTS.pageTitle).slice(0, 80),
|
||||||
|
faviconFile: data.faviconFile ?? null,
|
||||||
|
faviconMime: data.faviconMime ?? null,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(BRANDING_FILE, JSON.stringify(next, null, 2), 'utf8');
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function publicPayload(branding) {
|
||||||
|
const hasFavicon = Boolean(branding.faviconFile);
|
||||||
|
return {
|
||||||
|
title: branding.title,
|
||||||
|
pageTitle: branding.pageTitle,
|
||||||
|
hasFavicon,
|
||||||
|
faviconUrl: hasFavicon ? '/api/branding/favicon' : null,
|
||||||
|
updatedAt: branding.updatedAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateBranding({ title, pageTitle }) {
|
||||||
|
const current = readBranding();
|
||||||
|
return writeBranding({
|
||||||
|
...current,
|
||||||
|
title: title !== undefined ? title : current.title,
|
||||||
|
pageTitle: pageTitle !== undefined ? pageTitle : current.pageTitle,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeExistingFavicons() {
|
||||||
|
if (!fs.existsSync(BRANDING_DIR)) return;
|
||||||
|
for (const file of fs.readdirSync(BRANDING_DIR)) {
|
||||||
|
if (file.startsWith(`${FAVICON_BASENAME}.`)) {
|
||||||
|
fs.unlinkSync(path.join(BRANDING_DIR, file));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveFaviconFromBase64(dataUrl) {
|
||||||
|
const match = String(dataUrl || '').match(
|
||||||
|
/^data:([\w/+.-]+);base64,([A-Za-z0-9+/=]+)$/,
|
||||||
|
);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('Invalid image data. Upload a PNG, ICO, SVG, or WebP file.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const mime = match[1];
|
||||||
|
if (!ALLOWED_MIME.has(mime)) {
|
||||||
|
throw new Error('Unsupported image type. Use PNG, ICO, SVG, JPEG, or WebP.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const buffer = Buffer.from(match[2], 'base64');
|
||||||
|
if (buffer.length > 512 * 1024) {
|
||||||
|
throw new Error('Favicon must be 512 KB or smaller.');
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureDir();
|
||||||
|
removeExistingFavicons();
|
||||||
|
|
||||||
|
const ext = MIME_EXT[mime] || '.png';
|
||||||
|
const filename = `${FAVICON_BASENAME}${ext}`;
|
||||||
|
const filePath = path.join(BRANDING_DIR, filename);
|
||||||
|
fs.writeFileSync(filePath, buffer);
|
||||||
|
|
||||||
|
const current = readBranding();
|
||||||
|
return writeBranding({
|
||||||
|
...current,
|
||||||
|
faviconFile: filename,
|
||||||
|
faviconMime: mime,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFavicon() {
|
||||||
|
removeExistingFavicons();
|
||||||
|
const current = readBranding();
|
||||||
|
return writeBranding({
|
||||||
|
...current,
|
||||||
|
faviconFile: null,
|
||||||
|
faviconMime: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFaviconPath() {
|
||||||
|
const branding = readBranding();
|
||||||
|
if (!branding.faviconFile) return null;
|
||||||
|
const filePath = path.join(BRANDING_DIR, branding.faviconFile);
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
return { filePath, mime: branding.faviconMime || 'image/png' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetBranding() {
|
||||||
|
clearFavicon();
|
||||||
|
return writeBranding({ ...DEFAULTS });
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
readBranding,
|
||||||
|
writeBranding,
|
||||||
|
publicPayload,
|
||||||
|
updateBranding,
|
||||||
|
saveFaviconFromBase64,
|
||||||
|
clearFavicon,
|
||||||
|
getFaviconPath,
|
||||||
|
resetBranding,
|
||||||
|
DEFAULTS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,202 @@
|
|||||||
|
// SupportBot | Emerald Services
|
||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { fullPermissions, normalizePermissions } = require("./DashboardPermissions.js");
|
||||||
|
|
||||||
|
const GROUPS_FILE = path.join(__dirname, "../Data/dashboard-groups.json");
|
||||||
|
|
||||||
|
const DEFAULT_GROUPS = [
|
||||||
|
{
|
||||||
|
id: "owner",
|
||||||
|
name: "Owner",
|
||||||
|
description: "Full system owner access to all features and configs",
|
||||||
|
color: "#10B981", // Emerald Green
|
||||||
|
discordRoleId: null,
|
||||||
|
isSystem: true,
|
||||||
|
permissions: fullPermissions(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "admin",
|
||||||
|
name: "Admin",
|
||||||
|
description: "Full administrative access including user and group management",
|
||||||
|
color: "#3B82F6", // Blue
|
||||||
|
discordRoleId: null,
|
||||||
|
isSystem: true,
|
||||||
|
permissions: fullPermissions(false, false),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "support",
|
||||||
|
name: "Support Staff",
|
||||||
|
description: "Access to Overview, Live Tickets, and Transcripts",
|
||||||
|
color: "#8B5CF6", // Purple
|
||||||
|
discordRoleId: null,
|
||||||
|
isSystem: false,
|
||||||
|
permissions: {
|
||||||
|
overview: true,
|
||||||
|
tickets: true,
|
||||||
|
logs: false,
|
||||||
|
transcripts: true,
|
||||||
|
settings: { view: false, update: false },
|
||||||
|
configs: {
|
||||||
|
supportbot: { view: false, edit: false },
|
||||||
|
"ticket-panel": { view: false, edit: false },
|
||||||
|
commands: { view: false, edit: false },
|
||||||
|
messages: { view: false, edit: false },
|
||||||
|
"supportbot-ai": { view: false, edit: false },
|
||||||
|
api: { view: false, edit: false },
|
||||||
|
},
|
||||||
|
users: { view: false, manage: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "viewer",
|
||||||
|
name: "Viewer",
|
||||||
|
description: "Read-only access to dashboard and logs",
|
||||||
|
color: "#6B7280", // Gray
|
||||||
|
discordRoleId: null,
|
||||||
|
isSystem: false,
|
||||||
|
permissions: {
|
||||||
|
overview: true,
|
||||||
|
tickets: true,
|
||||||
|
logs: true,
|
||||||
|
transcripts: true,
|
||||||
|
settings: { view: true, update: false },
|
||||||
|
configs: {
|
||||||
|
supportbot: { view: true, edit: false },
|
||||||
|
"ticket-panel": { view: true, edit: false },
|
||||||
|
commands: { view: true, edit: false },
|
||||||
|
messages: { view: true, edit: false },
|
||||||
|
"supportbot-ai": { view: true, edit: false },
|
||||||
|
api: { view: true, edit: false },
|
||||||
|
},
|
||||||
|
users: { view: false, manage: false },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function ensureStorage() {
|
||||||
|
const dir = path.dirname(GROUPS_FILE);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!fs.existsSync(GROUPS_FILE)) {
|
||||||
|
const initialData = {
|
||||||
|
version: 1,
|
||||||
|
groups: DEFAULT_GROUPS,
|
||||||
|
};
|
||||||
|
fs.writeFileSync(GROUPS_FILE, JSON.stringify(initialData, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadStore() {
|
||||||
|
ensureStorage();
|
||||||
|
try {
|
||||||
|
const raw = fs.readFileSync(GROUPS_FILE, "utf8");
|
||||||
|
const parsed = JSON.parse(raw);
|
||||||
|
if (parsed && Array.isArray(parsed.groups)) {
|
||||||
|
return parsed.groups;
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[GroupStore] Error loading groups file:", err.message);
|
||||||
|
}
|
||||||
|
return DEFAULT_GROUPS;
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveStore(groups) {
|
||||||
|
ensureStorage();
|
||||||
|
const payload = {
|
||||||
|
version: 1,
|
||||||
|
groups,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
fs.writeFileSync(GROUPS_FILE, JSON.stringify(payload, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function listGroups() {
|
||||||
|
return loadStore();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGroup(id) {
|
||||||
|
const groups = loadStore();
|
||||||
|
return groups.find((g) => g.id === id) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createGroup(payload = {}) {
|
||||||
|
const groups = loadStore();
|
||||||
|
const name = (payload.name || "New Group").trim();
|
||||||
|
const slug = name.toLowerCase().replace(/[^a-z0-9_-]+/g, "_");
|
||||||
|
let newId = slug || `group_${Date.now()}`;
|
||||||
|
|
||||||
|
let counter = 1;
|
||||||
|
while (groups.some((g) => g.id === newId)) {
|
||||||
|
newId = `${slug}_${counter++}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const newGroup = {
|
||||||
|
id: newId,
|
||||||
|
name,
|
||||||
|
description: (payload.description || "").trim(),
|
||||||
|
color: payload.color || "#10B981",
|
||||||
|
discordRoleId: payload.discordRoleId ? String(payload.discordRoleId).trim() : null,
|
||||||
|
isSystem: false,
|
||||||
|
permissions: normalizePermissions(payload.permissions),
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
groups.push(newGroup);
|
||||||
|
saveStore(groups);
|
||||||
|
return newGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateGroup(id, payload = {}) {
|
||||||
|
if (id === "owner") {
|
||||||
|
throw new Error('System group "Owner" cannot be modified.');
|
||||||
|
}
|
||||||
|
const groups = loadStore();
|
||||||
|
const index = groups.findIndex((g) => g.id === id);
|
||||||
|
if (index === -1) {
|
||||||
|
throw new Error(`Group with ID "${id}" not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = groups[index];
|
||||||
|
const updatedGroup = {
|
||||||
|
...existing,
|
||||||
|
name: payload.name !== undefined ? payload.name.trim() : existing.name,
|
||||||
|
description: payload.description !== undefined ? payload.description.trim() : existing.description,
|
||||||
|
color: payload.color || existing.color,
|
||||||
|
discordRoleId: payload.discordRoleId !== undefined ? (payload.discordRoleId ? String(payload.discordRoleId).trim() : null) : existing.discordRoleId,
|
||||||
|
permissions: payload.permissions ? normalizePermissions(payload.permissions) : existing.permissions,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
};
|
||||||
|
|
||||||
|
groups[index] = updatedGroup;
|
||||||
|
saveStore(groups);
|
||||||
|
return updatedGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteGroup(id) {
|
||||||
|
const groups = loadStore();
|
||||||
|
const group = groups.find((g) => g.id === id);
|
||||||
|
if (!group) {
|
||||||
|
throw new Error(`Group with ID "${id}" not found.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (group.isSystem) {
|
||||||
|
throw new Error(`System group "${group.name}" cannot be deleted.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const filtered = groups.filter((g) => g.id !== id);
|
||||||
|
saveStore(filtered);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
listGroups,
|
||||||
|
getGroup,
|
||||||
|
createGroup,
|
||||||
|
updateGroup,
|
||||||
|
deleteGroup,
|
||||||
|
DEFAULT_GROUPS,
|
||||||
|
};
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
const CONFIG_FILES = [
|
||||||
|
"supportbot",
|
||||||
|
"ticket-panel",
|
||||||
|
"commands",
|
||||||
|
"messages",
|
||||||
|
"supportbot-ai",
|
||||||
|
"api",
|
||||||
|
];
|
||||||
|
|
||||||
|
const ROLES = ["owner", "admin", "moderator", "editor", "viewer", "custom"];
|
||||||
|
|
||||||
|
function configMap(view, edit) {
|
||||||
|
const configs = {};
|
||||||
|
for (const file of CONFIG_FILES) {
|
||||||
|
configs[file] = { view: Boolean(view), edit: Boolean(edit) };
|
||||||
|
}
|
||||||
|
return configs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fullPermissions(rawYaml = true, userManage = true) {
|
||||||
|
return {
|
||||||
|
overview: true,
|
||||||
|
tickets: true,
|
||||||
|
logs: true,
|
||||||
|
transcripts: true,
|
||||||
|
rawYaml: Boolean(rawYaml),
|
||||||
|
settings: { view: true, update: true },
|
||||||
|
configs: configMap(true, true),
|
||||||
|
users: { view: true, manage: Boolean(userManage) },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function permissionsFromRole(role, customPermissions) {
|
||||||
|
switch (role) {
|
||||||
|
case "owner":
|
||||||
|
return fullPermissions(true, true);
|
||||||
|
case "admin":
|
||||||
|
return fullPermissions(false, false);
|
||||||
|
case "moderator":
|
||||||
|
return {
|
||||||
|
overview: true,
|
||||||
|
tickets: true,
|
||||||
|
logs: true,
|
||||||
|
transcripts: true,
|
||||||
|
rawYaml: false,
|
||||||
|
settings: { view: true, update: false },
|
||||||
|
configs: configMap(true, false),
|
||||||
|
users: { view: false, manage: false },
|
||||||
|
};
|
||||||
|
case "editor":
|
||||||
|
return {
|
||||||
|
overview: true,
|
||||||
|
tickets: true,
|
||||||
|
logs: true,
|
||||||
|
transcripts: false,
|
||||||
|
rawYaml: false,
|
||||||
|
settings: { view: true, update: false },
|
||||||
|
configs: configMap(true, true),
|
||||||
|
users: { view: false, manage: false },
|
||||||
|
};
|
||||||
|
case "viewer":
|
||||||
|
return {
|
||||||
|
overview: true,
|
||||||
|
tickets: true,
|
||||||
|
logs: true,
|
||||||
|
transcripts: true,
|
||||||
|
rawYaml: false,
|
||||||
|
settings: { view: true, update: false },
|
||||||
|
configs: configMap(true, false),
|
||||||
|
users: { view: false, manage: false },
|
||||||
|
};
|
||||||
|
case "custom":
|
||||||
|
return normalizePermissions(customPermissions);
|
||||||
|
default:
|
||||||
|
return permissionsFromRole("viewer");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizePermissions(input) {
|
||||||
|
const base = permissionsFromRole("viewer");
|
||||||
|
if (!input || typeof input !== "object") return base;
|
||||||
|
|
||||||
|
return {
|
||||||
|
overview: input.overview ?? base.overview,
|
||||||
|
tickets: input.tickets ?? input.overview ?? base.tickets,
|
||||||
|
logs: input.logs ?? base.logs,
|
||||||
|
transcripts: input.transcripts ?? base.transcripts,
|
||||||
|
rawYaml: Boolean(input.rawYaml ?? input.configs?.rawYaml ?? base.rawYaml),
|
||||||
|
settings: {
|
||||||
|
view: input.settings?.view ?? base.settings.view,
|
||||||
|
update: input.settings?.update ?? base.settings.update,
|
||||||
|
},
|
||||||
|
configs: CONFIG_FILES.reduce((acc, file) => {
|
||||||
|
acc[file] = {
|
||||||
|
view: input.configs?.[file]?.view ?? base.configs[file].view,
|
||||||
|
edit: input.configs?.[file]?.edit ?? base.configs[file].edit,
|
||||||
|
};
|
||||||
|
return acc;
|
||||||
|
}, {}),
|
||||||
|
users: {
|
||||||
|
view: input.users?.view ?? base.users.view,
|
||||||
|
manage: input.users?.manage ?? base.users.manage,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPermission(permissions, check) {
|
||||||
|
if (!permissions) return false;
|
||||||
|
|
||||||
|
if (check === "overview") return Boolean(permissions.overview);
|
||||||
|
if (check === "tickets") return Boolean(permissions.tickets ?? permissions.overview);
|
||||||
|
if (check === "logs") return Boolean(permissions.logs);
|
||||||
|
if (check === "transcripts") return Boolean(permissions.transcripts);
|
||||||
|
if (check === "configs.raw_yaml" || check === "configs.rawYaml" || check === "rawYaml") {
|
||||||
|
return Boolean(permissions.rawYaml ?? permissions.configs?.rawYaml ?? false);
|
||||||
|
}
|
||||||
|
if (check === "settings.view") return Boolean(permissions.settings?.view);
|
||||||
|
if (check === "settings.update") return Boolean(permissions.settings?.update);
|
||||||
|
if (check === "users.view") return Boolean(permissions.users?.view);
|
||||||
|
if (check === "users.manage") return Boolean(permissions.users?.manage);
|
||||||
|
|
||||||
|
const configView = check.match(/^configs\.([a-z0-9-]+)\.view$/);
|
||||||
|
if (configView) {
|
||||||
|
const file = configView[1];
|
||||||
|
return Boolean(permissions.configs?.[file]?.view);
|
||||||
|
}
|
||||||
|
|
||||||
|
const configEdit = check.match(/^configs\.([a-z0-9-]+)\.edit$/);
|
||||||
|
if (configEdit) {
|
||||||
|
const file = configEdit[1];
|
||||||
|
return Boolean(permissions.configs?.[file]?.edit);
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function canAccessConfig(permissions, file, mode = "view") {
|
||||||
|
if (!CONFIG_FILES.includes(file)) return false;
|
||||||
|
return hasPermission(permissions, `configs.${file}.${mode}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CONFIG_FILES,
|
||||||
|
ROLES,
|
||||||
|
fullPermissions,
|
||||||
|
permissionsFromRole,
|
||||||
|
normalizePermissions,
|
||||||
|
hasPermission,
|
||||||
|
canAccessConfig,
|
||||||
|
};
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
|
||||||
|
const SESSION_COOKIE = "sb_dashboard";
|
||||||
|
const SESSION_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||||
|
|
||||||
|
function signSession(payload, secret) {
|
||||||
|
const body = Buffer.from(JSON.stringify(payload)).toString("base64url");
|
||||||
|
const sig = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(body)
|
||||||
|
.digest("base64url");
|
||||||
|
return `${body}.${sig}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function verifySession(token, secret) {
|
||||||
|
if (!token || typeof token !== "string") return null;
|
||||||
|
const dot = token.lastIndexOf(".");
|
||||||
|
if (dot <= 0) return null;
|
||||||
|
|
||||||
|
const body = token.slice(0, dot);
|
||||||
|
const sig = token.slice(dot + 1);
|
||||||
|
const expected = crypto
|
||||||
|
.createHmac("sha256", secret)
|
||||||
|
.update(body)
|
||||||
|
.digest("base64url");
|
||||||
|
|
||||||
|
if (sig.length !== expected.length) return null;
|
||||||
|
if (!crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected))) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const payload = JSON.parse(
|
||||||
|
Buffer.from(body, "base64url").toString("utf8"),
|
||||||
|
);
|
||||||
|
if (!payload?.userId || !payload?.exp) return null;
|
||||||
|
if (Date.now() > payload.exp) return null;
|
||||||
|
return payload;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSessionPayload(user) {
|
||||||
|
return {
|
||||||
|
userId: user.id,
|
||||||
|
username: user.username,
|
||||||
|
globalName: user.global_name || null,
|
||||||
|
avatar: user.avatar || null,
|
||||||
|
exp: Date.now() + SESSION_MAX_AGE_MS,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function sessionCookieOptions(maxAgeMs = SESSION_MAX_AGE_MS, opts = {}) {
|
||||||
|
const secure =
|
||||||
|
opts.secure !== undefined
|
||||||
|
? Boolean(opts.secure)
|
||||||
|
: process.env.NODE_ENV === "production";
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure,
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: maxAgeMs,
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
SESSION_COOKIE,
|
||||||
|
SESSION_MAX_AGE_MS,
|
||||||
|
signSession,
|
||||||
|
verifySession,
|
||||||
|
createSessionPayload,
|
||||||
|
sessionCookieOptions,
|
||||||
|
};
|
||||||
@@ -0,0 +1,510 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const axios = require("axios");
|
||||||
|
const YAML = require("yaml");
|
||||||
|
const configStore = require("./ConfigStore.js");
|
||||||
|
const { CALLBACK_PATH } = require("./DiscordOAuth.js");
|
||||||
|
|
||||||
|
const DISCORD_API = "https://discord.com/api/v10";
|
||||||
|
const CONFIG_DIR = path.join(__dirname, "../Configs");
|
||||||
|
const API_FILE = path.join(CONFIG_DIR, "api.yml");
|
||||||
|
const SUPPORTBOT_FILE = path.join(CONFIG_DIR, "supportbot.yml");
|
||||||
|
|
||||||
|
const PLACEHOLDER_PATTERNS = [
|
||||||
|
/DISCORD_CLIENT_ID/i,
|
||||||
|
/DISCORD_CLIENT_SECRET/i,
|
||||||
|
/YOUR_DISCORD_USER_ID/i,
|
||||||
|
/CHANGE_ME/i,
|
||||||
|
/BOT_TOKEN/i,
|
||||||
|
/PUT_RANDOM_STRING/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function isEmpty(value) {
|
||||||
|
if (value == null) return true;
|
||||||
|
if (typeof value === "boolean") return false;
|
||||||
|
if (typeof value === "number") return false;
|
||||||
|
return String(value).trim() === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaceholder(value) {
|
||||||
|
if (isEmpty(value)) return true;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return PLACEHOLDER_PATTERNS.some((p) => p.test(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDiscordSnowflake(value) {
|
||||||
|
return /^\d{17,20}$/.test(String(value).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidBotToken(value) {
|
||||||
|
if (isPlaceholder(value)) return false;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return s.length >= 50 && s.includes(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidSecretKey(value) {
|
||||||
|
if (isPlaceholder(value)) return false;
|
||||||
|
return String(value).trim().length >= 16;
|
||||||
|
}
|
||||||
|
|
||||||
|
function readApiRoot() {
|
||||||
|
if (!fs.existsSync(API_FILE)) return null;
|
||||||
|
try {
|
||||||
|
const raw = YAML.parse(fs.readFileSync(API_FILE, "utf8"));
|
||||||
|
return raw?.API ?? raw ?? null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function readSupportbotRoot() {
|
||||||
|
if (!fs.existsSync(SUPPORTBOT_FILE)) return {};
|
||||||
|
try {
|
||||||
|
return YAML.parse(fs.readFileSync(SUPPORTBOT_FILE, "utf8")) || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnerIds(oauth) {
|
||||||
|
const ids = [];
|
||||||
|
for (const id of oauth?.OwnerUserIds || []) {
|
||||||
|
const s = String(id).trim();
|
||||||
|
if (s) ids.push(s);
|
||||||
|
}
|
||||||
|
if (ids.length === 0) {
|
||||||
|
for (const id of oauth?.AllowedUserIds || []) {
|
||||||
|
const s = String(id).trim();
|
||||||
|
if (s) ids.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSteps(apiRoot, supportbot) {
|
||||||
|
const oauth = apiRoot?.OAuth || {};
|
||||||
|
const token = supportbot?.General?.Token;
|
||||||
|
const dbConfig = supportbot?.Database || {};
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
id: "botToken",
|
||||||
|
title: "Bot token",
|
||||||
|
description: "From the Discord Developer Portal → your application → Bot → Reset Token.",
|
||||||
|
complete: isValidBotToken(token),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "database",
|
||||||
|
title: "Database storage",
|
||||||
|
description: "Choose SQLite or MySQL database engine for storing bot data.",
|
||||||
|
complete: Boolean(dbConfig.Driver),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "secretKey",
|
||||||
|
title: "Dashboard session secret",
|
||||||
|
description: "Random string used to sign login cookies. Keep it private.",
|
||||||
|
complete: isValidSecretKey(apiRoot?.SecretKey),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "oauth",
|
||||||
|
title: "Discord OAuth app",
|
||||||
|
description: "Same application as your bot — Client ID and Client Secret from OAuth2.",
|
||||||
|
complete:
|
||||||
|
!isPlaceholder(oauth.ClientId) &&
|
||||||
|
!isPlaceholder(oauth.ClientSecret) &&
|
||||||
|
isDiscordSnowflake(oauth.ClientId),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "redirectUri",
|
||||||
|
title: "OAuth redirect URL",
|
||||||
|
description: `Add this exact URL under OAuth2 → Redirects in the Discord portal.`,
|
||||||
|
complete: isValidRedirectUriConfigured(oauth.RedirectUri),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "owners",
|
||||||
|
title: "Dashboard owners",
|
||||||
|
description: "Discord user IDs that can sign in and manage the dashboard (at least one).",
|
||||||
|
complete: getOwnerIds(oauth).length > 0 && getOwnerIds(oauth).every(isDiscordSnowflake),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "emeraldApi",
|
||||||
|
title: "Emerald API",
|
||||||
|
description: "Optional. Used for the one-click addon installer.",
|
||||||
|
complete: true,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidRedirectUriConfigured(uri) {
|
||||||
|
if (isPlaceholder(uri)) return false;
|
||||||
|
try {
|
||||||
|
const u = new URL(String(uri).trim());
|
||||||
|
if (!["http:", "https:"].includes(u.protocol)) return false;
|
||||||
|
return u.pathname.replace(/\/$/, "") === CALLBACK_PATH.replace(/\/$/, "");
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSetupComplete() {
|
||||||
|
const apiRoot = readApiRoot();
|
||||||
|
if (!apiRoot?.Enabled) return false;
|
||||||
|
const oauth = apiRoot.OAuth || {};
|
||||||
|
if (!oauth.Enabled) return false;
|
||||||
|
const steps = buildSteps(apiRoot, readSupportbotRoot());
|
||||||
|
return steps.every((s) => s.complete);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSetupStatus(req) {
|
||||||
|
const apiRoot = readApiRoot() || {};
|
||||||
|
const steps = buildSteps(apiRoot, readSupportbotRoot());
|
||||||
|
const complete = steps.every((s) => s.complete);
|
||||||
|
|
||||||
|
let suggestedRedirectUri = null;
|
||||||
|
if (req) {
|
||||||
|
const proto =
|
||||||
|
req.get("x-forwarded-proto")?.split(",")[0]?.trim() ||
|
||||||
|
req.protocol ||
|
||||||
|
"http";
|
||||||
|
const host = req.get("x-forwarded-host")?.split(",")[0]?.trim() || req.get("host");
|
||||||
|
if (host) {
|
||||||
|
suggestedRedirectUri = `${proto}://${host}${CALLBACK_PATH}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!suggestedRedirectUri) {
|
||||||
|
suggestedRedirectUri = `http://localhost:${apiRoot.Port || 3000}${CALLBACK_PATH}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
complete,
|
||||||
|
steps,
|
||||||
|
suggestedRedirectUri,
|
||||||
|
oauthCallbackPath: CALLBACK_PATH,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateBotToken(token) {
|
||||||
|
const trimmed = String(token || "").trim();
|
||||||
|
if (!isValidBotToken(trimmed)) {
|
||||||
|
return { ok: false, error: "Enter a valid bot token from the Discord Developer Portal." };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [userRes, appRes] = await Promise.all([
|
||||||
|
axios.get(`${DISCORD_API}/users/@me`, {
|
||||||
|
headers: { Authorization: `Bot ${trimmed}` },
|
||||||
|
timeout: 12_000,
|
||||||
|
}),
|
||||||
|
axios.get(`${DISCORD_API}/oauth2/applications/@me`, {
|
||||||
|
headers: { Authorization: `Bot ${trimmed}` },
|
||||||
|
timeout: 12_000,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
bot: {
|
||||||
|
id: userRes.data.id,
|
||||||
|
username: userRes.data.username,
|
||||||
|
discriminator: userRes.data.discriminator,
|
||||||
|
},
|
||||||
|
applicationId: String(appRes.data.id),
|
||||||
|
};
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.response?.status;
|
||||||
|
if (status === 401) {
|
||||||
|
return { ok: false, error: "Discord rejected this bot token (401 Unauthorized)." };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err.response?.data?.message || err.message || "Could not verify bot token.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateOAuthCredentials(clientId, clientSecret, botToken) {
|
||||||
|
const id = String(clientId || "").trim();
|
||||||
|
const secret = String(clientSecret || "").trim();
|
||||||
|
|
||||||
|
if (!isDiscordSnowflake(id)) {
|
||||||
|
return { ok: false, error: "Client ID must be a numeric Discord application ID." };
|
||||||
|
}
|
||||||
|
if (isPlaceholder(secret) || secret.length < 20) {
|
||||||
|
return { ok: false, error: "Enter the OAuth Client Secret from the Discord portal." };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (botToken) {
|
||||||
|
const botCheck = await validateBotToken(botToken);
|
||||||
|
if (botCheck.ok && botCheck.applicationId && botCheck.applicationId !== id) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Client ID does not match the bot application (${botCheck.applicationId}). Use the same app's credentials.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await axios.post(
|
||||||
|
`${DISCORD_API}/oauth2/token`,
|
||||||
|
new URLSearchParams({ grant_type: "client_credentials", scope: "identify" }),
|
||||||
|
{
|
||||||
|
auth: { username: id, password: secret },
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: 12_000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
return { ok: true, clientId: id };
|
||||||
|
} catch (err) {
|
||||||
|
const status = err.response?.status;
|
||||||
|
if (botToken) {
|
||||||
|
const botCheck = await validateBotToken(botToken);
|
||||||
|
if (botCheck.ok && botCheck.applicationId === id) {
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
clientId: id,
|
||||||
|
warning:
|
||||||
|
"Could not verify the client secret with Discord (token endpoint). ID matches your bot — double-check the secret in the portal.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (status === 401 || status === 400) {
|
||||||
|
return { ok: false, error: "Invalid Client ID or Client Secret." };
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: err.response?.data?.error_description || err.message || "OAuth validation failed.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateRedirectUri(uri) {
|
||||||
|
const trimmed = String(uri || "").trim();
|
||||||
|
if (!isValidRedirectUriConfigured(trimmed)) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `Redirect URL must end with ${CALLBACK_PATH} and use http or https.`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: true, redirectUri: trimmed };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateOwnerUserIds(ownerIds, botToken) {
|
||||||
|
const ids = (Array.isArray(ownerIds) ? ownerIds : [ownerIds])
|
||||||
|
.map((id) => String(id).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
if (ids.length === 0) {
|
||||||
|
return { ok: false, error: "Add at least one Discord user ID." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const invalid = ids.filter((id) => !isDiscordSnowflake(id));
|
||||||
|
if (invalid.length) {
|
||||||
|
return { ok: false, error: `Invalid user ID: ${invalid[0]}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!botToken || !isValidBotToken(botToken)) {
|
||||||
|
return { ok: true, ownerUserIds: ids, warning: "User IDs format OK (bot token not verified yet)." };
|
||||||
|
}
|
||||||
|
|
||||||
|
const users = [];
|
||||||
|
try {
|
||||||
|
for (const id of ids) {
|
||||||
|
const res = await axios.get(`${DISCORD_API}/users/${id}`, {
|
||||||
|
headers: { Authorization: `Bot ${String(botToken).trim()}` },
|
||||||
|
timeout: 12_000,
|
||||||
|
});
|
||||||
|
users.push({
|
||||||
|
id: res.data.id,
|
||||||
|
username: res.data.username,
|
||||||
|
globalName: res.data.global_name || null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { ok: true, ownerUserIds: ids, users };
|
||||||
|
} catch (err) {
|
||||||
|
if (err.response?.status === 404) {
|
||||||
|
return { ok: false, error: "One or more user IDs were not found. Check Developer Mode → Copy User ID." };
|
||||||
|
}
|
||||||
|
if (err.response?.status === 403) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: "Bot could not look up users. Ensure the bot is in a server with those users, or verify IDs manually.",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { ok: false, error: err.message || "Failed to verify owner user IDs." };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function validateSecretKey(secretKey) {
|
||||||
|
if (!isValidSecretKey(secretKey)) {
|
||||||
|
return { ok: false, error: "Use at least 16 characters (not CHANGE_ME)." };
|
||||||
|
}
|
||||||
|
return { ok: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateDatabase(dbDriver, dbHost, dbPort, dbUser, dbPassword, dbDatabase) {
|
||||||
|
const driver = String(dbDriver || "sqlite").toLowerCase();
|
||||||
|
if (driver === "mysql" || driver === "mariadb") {
|
||||||
|
const host = String(dbHost || "").trim();
|
||||||
|
const port = Number(dbPort) || 3306;
|
||||||
|
const user = String(dbUser || "").trim();
|
||||||
|
const password = String(dbPassword || "");
|
||||||
|
const database = String(dbDatabase || "").trim();
|
||||||
|
|
||||||
|
if (isEmpty(host)) return { ok: false, error: "MySQL Host is required." };
|
||||||
|
if (isEmpty(user)) return { ok: false, error: "MySQL User is required." };
|
||||||
|
if (isEmpty(database)) return { ok: false, error: "MySQL Database Name is required." };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const mysql = require("mysql2/promise");
|
||||||
|
const connection = await mysql.createConnection({
|
||||||
|
host,
|
||||||
|
port,
|
||||||
|
user,
|
||||||
|
password,
|
||||||
|
database,
|
||||||
|
connectTimeout: 5000,
|
||||||
|
});
|
||||||
|
await connection.end();
|
||||||
|
return { ok: true, driver: "mysql" };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
ok: false,
|
||||||
|
error: `MySQL Connection Failed: ${err.message || err.code || "Could not connect to database host."}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ok: true, driver: "sqlite" };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateSetupPayload(payload) {
|
||||||
|
const results = {};
|
||||||
|
|
||||||
|
if (payload.botToken != null) {
|
||||||
|
results.botToken = await validateBotToken(payload.botToken);
|
||||||
|
}
|
||||||
|
if (payload.dbDriver != null) {
|
||||||
|
results.database = await validateDatabase(
|
||||||
|
payload.dbDriver,
|
||||||
|
payload.dbHost,
|
||||||
|
payload.dbPort,
|
||||||
|
payload.dbUser,
|
||||||
|
payload.dbPassword,
|
||||||
|
payload.dbDatabase,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.secretKey != null) {
|
||||||
|
results.secretKey = validateSecretKey(payload.secretKey);
|
||||||
|
}
|
||||||
|
if (payload.clientId != null && payload.clientSecret != null) {
|
||||||
|
results.oauth = await validateOAuthCredentials(
|
||||||
|
payload.clientId,
|
||||||
|
payload.clientSecret,
|
||||||
|
payload.botToken,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (payload.redirectUri != null) {
|
||||||
|
results.redirectUri = validateRedirectUri(payload.redirectUri);
|
||||||
|
}
|
||||||
|
if (payload.ownerUserIds != null) {
|
||||||
|
results.owners = await validateOwnerUserIds(payload.ownerUserIds, payload.botToken);
|
||||||
|
}
|
||||||
|
if (payload.emeraldApiKey != null) {
|
||||||
|
const key = String(payload.emeraldApiKey).trim();
|
||||||
|
if (key === "") {
|
||||||
|
results.emeraldApi = { ok: true };
|
||||||
|
} else if (key.length < 10) {
|
||||||
|
results.emeraldApi = { ok: false, error: "Invalid API key." };
|
||||||
|
} else {
|
||||||
|
results.emeraldApi = { ok: true };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const allOk = Object.values(results).every((r) => r?.ok);
|
||||||
|
return { ok: allOk, results };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureApiFile() {
|
||||||
|
if (fs.existsSync(API_FILE)) return;
|
||||||
|
const example = path.join(CONFIG_DIR, "api.yml.example");
|
||||||
|
if (fs.existsSync(example)) {
|
||||||
|
fs.copyFileSync(example, API_FILE);
|
||||||
|
configStore.reloadFile("api");
|
||||||
|
} else {
|
||||||
|
throw new Error("Configs/api.yml is missing. Copy Configs/api.yml.example to api.yml.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applySetup(payload) {
|
||||||
|
ensureApiFile();
|
||||||
|
|
||||||
|
const ownerIds = (Array.isArray(payload.ownerUserIds)
|
||||||
|
? payload.ownerUserIds
|
||||||
|
: String(payload.ownerUserIds || "").split(/[\s,]+/)
|
||||||
|
)
|
||||||
|
.map((id) => String(id).trim())
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
const apiDoc = YAML.parseDocument(fs.readFileSync(API_FILE, "utf8"));
|
||||||
|
if (!apiDoc.get("API")) apiDoc.set("API", {});
|
||||||
|
|
||||||
|
apiDoc.setIn(["API", "Enabled"], true);
|
||||||
|
apiDoc.setIn(["API", "SecretKey"], String(payload.secretKey).trim());
|
||||||
|
if (payload.emeraldApiKey != null) {
|
||||||
|
apiDoc.setIn(["API", "EmeraldAPIKey"], String(payload.emeraldApiKey).trim());
|
||||||
|
}
|
||||||
|
if (payload.port != null && payload.port !== "") {
|
||||||
|
apiDoc.setIn(["API", "Port"], Number(payload.port) || 3000);
|
||||||
|
}
|
||||||
|
if (!apiDoc.getIn(["API", "OAuth"])) apiDoc.setIn(["API", "OAuth"], {});
|
||||||
|
|
||||||
|
apiDoc.setIn(["API", "OAuth", "Enabled"], true);
|
||||||
|
apiDoc.setIn(["API", "OAuth", "ClientId"], String(payload.clientId).trim());
|
||||||
|
apiDoc.setIn(["API", "OAuth", "ClientSecret"], String(payload.clientSecret).trim());
|
||||||
|
apiDoc.setIn(["API", "OAuth", "RedirectUri"], String(payload.redirectUri).trim());
|
||||||
|
apiDoc.setIn(["API", "OAuth", "OwnerUserIds"], ownerIds);
|
||||||
|
apiDoc.setIn(["API", "OAuth", "AllowedUserIds"], []);
|
||||||
|
|
||||||
|
fs.writeFileSync(API_FILE, apiDoc.toString());
|
||||||
|
|
||||||
|
const sbDoc = YAML.parseDocument(fs.readFileSync(SUPPORTBOT_FILE, "utf8"));
|
||||||
|
if (payload.botToken != null && String(payload.botToken).trim() !== "") {
|
||||||
|
sbDoc.setIn(["General", "Token"], String(payload.botToken).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.dbDriver != null) {
|
||||||
|
const driver = String(payload.dbDriver).toLowerCase() === "mysql" ? "mysql" : "sqlite";
|
||||||
|
sbDoc.setIn(["Database", "Driver"], driver);
|
||||||
|
if (driver === "mysql") {
|
||||||
|
sbDoc.setIn(["Database", "MySQL", "Host"], String(payload.dbHost || "127.0.0.1").trim());
|
||||||
|
sbDoc.setIn(["Database", "MySQL", "Port"], Number(payload.dbPort) || 3306);
|
||||||
|
sbDoc.setIn(["Database", "MySQL", "User"], String(payload.dbUser || "").trim());
|
||||||
|
sbDoc.setIn(["Database", "MySQL", "Password"], String(payload.dbPassword || ""));
|
||||||
|
sbDoc.setIn(["Database", "MySQL", "Database"], String(payload.dbDatabase || "").trim());
|
||||||
|
} else {
|
||||||
|
sbDoc.setIn(["Database", "SQLite", "File"], "./Data/supportbot.db");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fs.writeFileSync(SUPPORTBOT_FILE, sbDoc.toString());
|
||||||
|
|
||||||
|
configStore.reloadFile("api");
|
||||||
|
configStore.reloadFile("supportbot");
|
||||||
|
|
||||||
|
return {
|
||||||
|
ownerUserIds: ownerIds,
|
||||||
|
api: configStore.api?.API || configStore.api,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
isSetupComplete,
|
||||||
|
getSetupStatus,
|
||||||
|
validateBotToken,
|
||||||
|
validateOAuthCredentials,
|
||||||
|
validateRedirectUri,
|
||||||
|
validateOwnerUserIds,
|
||||||
|
validateSecretKey,
|
||||||
|
validateSetupPayload,
|
||||||
|
applySetup,
|
||||||
|
};
|
||||||
@@ -0,0 +1,311 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const {
|
||||||
|
CONFIG_FILES,
|
||||||
|
ROLES,
|
||||||
|
fullPermissions,
|
||||||
|
permissionsFromRole,
|
||||||
|
normalizePermissions,
|
||||||
|
hasPermission,
|
||||||
|
canAccessConfig,
|
||||||
|
} = require("./DashboardPermissions.js");
|
||||||
|
const { discordAvatarUrl } = require("./DiscordOAuth.js");
|
||||||
|
|
||||||
|
const STORE_PATH = path.join(__dirname, "../Data/dashboard-users.json");
|
||||||
|
|
||||||
|
function ensureDataDir() {
|
||||||
|
const dir = path.dirname(STORE_PATH);
|
||||||
|
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
function emptyStore() {
|
||||||
|
return { version: 1, users: {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readStore() {
|
||||||
|
ensureDataDir();
|
||||||
|
if (!fs.existsSync(STORE_PATH)) return emptyStore();
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(fs.readFileSync(STORE_PATH, "utf8"));
|
||||||
|
if (!raw.users || typeof raw.users !== "object") return emptyStore();
|
||||||
|
return raw;
|
||||||
|
} catch {
|
||||||
|
return emptyStore();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeStore(store) {
|
||||||
|
ensureDataDir();
|
||||||
|
fs.writeFileSync(STORE_PATH, JSON.stringify(store, null, 2), "utf8");
|
||||||
|
}
|
||||||
|
|
||||||
|
function getOwnerIds(oauth) {
|
||||||
|
const owners = new Set();
|
||||||
|
for (const id of oauth?.OwnerUserIds || []) owners.add(String(id));
|
||||||
|
for (const id of oauth?.AllowedUserIds || []) owners.add(String(id));
|
||||||
|
return owners;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isYamlOwner(userId, oauth) {
|
||||||
|
return getOwnerIds(oauth).has(String(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function bootstrapFromOAuth(oauth) {
|
||||||
|
const store = readStore();
|
||||||
|
const owners = getOwnerIds(oauth);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
for (const id of owners) {
|
||||||
|
if (!store.users[id]) {
|
||||||
|
store.users[id] = {
|
||||||
|
id,
|
||||||
|
username: null,
|
||||||
|
globalName: null,
|
||||||
|
avatar: null,
|
||||||
|
role: "owner",
|
||||||
|
permissions: null,
|
||||||
|
enabled: true,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: "system",
|
||||||
|
};
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) writeStore(store);
|
||||||
|
return store;
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncDiscordProfile(userId, profile) {
|
||||||
|
const store = readStore();
|
||||||
|
const entry = store.users[userId];
|
||||||
|
if (!entry) return;
|
||||||
|
|
||||||
|
entry.username = profile.username ?? entry.username;
|
||||||
|
entry.globalName = profile.globalName ?? entry.globalName;
|
||||||
|
entry.avatar = profile.avatar ?? entry.avatar;
|
||||||
|
entry.updatedAt = Date.now();
|
||||||
|
writeStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getGroup } = require("./DashboardGroupStore.js");
|
||||||
|
|
||||||
|
function resolvePermissions(entry, oauth, userId) {
|
||||||
|
if (isYamlOwner(userId, oauth)) {
|
||||||
|
return { permissions: fullPermissions(), role: "owner", isOwner: true, groupId: "owner" };
|
||||||
|
}
|
||||||
|
if (!entry?.enabled) return null;
|
||||||
|
|
||||||
|
const groupId = entry.groupId || entry.role || "support";
|
||||||
|
const group = getGroup(groupId);
|
||||||
|
|
||||||
|
if (entry.role === "custom" && entry.permissions) {
|
||||||
|
return { permissions: normalizePermissions(entry.permissions), role: "custom", isOwner: false, groupId };
|
||||||
|
}
|
||||||
|
|
||||||
|
const permissions = group ? group.permissions : permissionsFromRole(entry.role || "viewer");
|
||||||
|
return { permissions, role: entry.role || groupId, isOwner: false, groupId };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAccess(userId, oauth) {
|
||||||
|
bootstrapFromOAuth(oauth);
|
||||||
|
const store = readStore();
|
||||||
|
const id = String(userId);
|
||||||
|
|
||||||
|
if (isYamlOwner(id, oauth)) {
|
||||||
|
const entry = store.users[id];
|
||||||
|
return {
|
||||||
|
entry: entry || { id, role: "owner" },
|
||||||
|
permissions: fullPermissions(),
|
||||||
|
role: "owner",
|
||||||
|
isOwner: true,
|
||||||
|
yamlOwner: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = store.users[id];
|
||||||
|
const resolved = resolvePermissions(entry, oauth, id);
|
||||||
|
if (!resolved) return null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
entry,
|
||||||
|
permissions: resolved.permissions,
|
||||||
|
role: resolved.role,
|
||||||
|
isOwner: false,
|
||||||
|
yamlOwner: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function canLogin(userId, oauth) {
|
||||||
|
return Boolean(getAccess(userId, oauth));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPublicUser(entry, access, discordProfile) {
|
||||||
|
const id = String(entry?.id || discordProfile?.id);
|
||||||
|
const avatarHash = entry?.avatar ?? discordProfile?.avatar ?? null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
username: entry?.username || discordProfile?.username || "Unknown",
|
||||||
|
globalName: entry?.globalName ?? discordProfile?.global_name ?? null,
|
||||||
|
avatar: avatarHash
|
||||||
|
? discordAvatarUrl({ id, avatar: avatarHash })
|
||||||
|
: discordProfile
|
||||||
|
? discordAvatarUrl(discordProfile)
|
||||||
|
: discordAvatarUrl({ id, avatar: null }),
|
||||||
|
role: access.role,
|
||||||
|
isOwner: access.isOwner,
|
||||||
|
permissions: access.permissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listUsers(oauth, discordClient) {
|
||||||
|
bootstrapFromOAuth(oauth);
|
||||||
|
const store = readStore();
|
||||||
|
const yamlOwners = getOwnerIds(oauth);
|
||||||
|
|
||||||
|
let updated = false;
|
||||||
|
|
||||||
|
const usersList = Object.values(store.users).map((entry) => {
|
||||||
|
if (discordClient?.users?.cache) {
|
||||||
|
const cached = discordClient.users.cache.get(String(entry.id));
|
||||||
|
if (cached && (!entry.username || !entry.avatar)) {
|
||||||
|
entry.username = cached.username || entry.username;
|
||||||
|
entry.globalName = cached.globalName || cached.username || entry.globalName;
|
||||||
|
entry.avatar = cached.avatar || entry.avatar;
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = resolvePermissions(entry, oauth, entry.id) || {
|
||||||
|
permissions: permissionsFromRole("viewer"),
|
||||||
|
role: entry.role,
|
||||||
|
isOwner: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: entry.id,
|
||||||
|
username: entry.username || (yamlOwners.has(String(entry.id)) ? "Owner" : "User"),
|
||||||
|
globalName: entry.globalName || entry.username || null,
|
||||||
|
avatar: entry.avatar
|
||||||
|
? discordAvatarUrl({ id: entry.id, avatar: entry.avatar })
|
||||||
|
: discordAvatarUrl({ id: entry.id, avatar: null }),
|
||||||
|
role: yamlOwners.has(String(entry.id)) ? "owner" : entry.role || entry.groupId || "support",
|
||||||
|
groupId: entry.groupId || entry.role || "support",
|
||||||
|
permissions:
|
||||||
|
entry.role === "custom"
|
||||||
|
? normalizePermissions(entry.permissions)
|
||||||
|
: access.permissions,
|
||||||
|
enabled: entry.enabled !== false,
|
||||||
|
isOwner: yamlOwners.has(String(entry.id)),
|
||||||
|
yamlOwner: yamlOwners.has(String(entry.id)),
|
||||||
|
createdAt: entry.createdAt,
|
||||||
|
updatedAt: entry.updatedAt,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
if (updated) {
|
||||||
|
writeStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
return usersList;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addUser(payload, actorId, oauth) {
|
||||||
|
const id = String(payload.id || "").trim();
|
||||||
|
if (!/^\d{17,20}$/.test(id)) {
|
||||||
|
throw new Error("Invalid Discord user ID");
|
||||||
|
}
|
||||||
|
|
||||||
|
const role = payload.role || payload.groupId || "support";
|
||||||
|
const groupId = payload.groupId || payload.role || "support";
|
||||||
|
|
||||||
|
const store = readStore();
|
||||||
|
if (store.users[id]) throw new Error("User already exists");
|
||||||
|
|
||||||
|
store.users[id] = {
|
||||||
|
id,
|
||||||
|
username: payload.username || null,
|
||||||
|
globalName: payload.globalName || null,
|
||||||
|
avatar: null,
|
||||||
|
role,
|
||||||
|
groupId,
|
||||||
|
permissions:
|
||||||
|
role === "custom" ? normalizePermissions(payload.permissions) : null,
|
||||||
|
enabled: payload.enabled !== false,
|
||||||
|
createdAt: Date.now(),
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
createdBy: String(actorId),
|
||||||
|
};
|
||||||
|
|
||||||
|
writeStore(store);
|
||||||
|
return listUsers(oauth).find((u) => u.id === id);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUser(id, payload, actorId, oauth) {
|
||||||
|
const store = readStore();
|
||||||
|
const entry = store.users[String(id)];
|
||||||
|
if (!entry) throw new Error("User not found");
|
||||||
|
|
||||||
|
if (isYamlOwner(id, oauth) && payload.enabled === false) {
|
||||||
|
throw new Error("Cannot disable an owner defined in api.yml");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.groupId) {
|
||||||
|
entry.groupId = payload.groupId;
|
||||||
|
if (!payload.role) entry.role = payload.groupId;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.role) {
|
||||||
|
entry.role = payload.role;
|
||||||
|
if (!payload.groupId) entry.groupId = payload.role;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payload.enabled !== undefined) entry.enabled = Boolean(payload.enabled);
|
||||||
|
if (payload.username !== undefined) entry.username = payload.username;
|
||||||
|
if (payload.globalName !== undefined) entry.globalName = payload.globalName;
|
||||||
|
|
||||||
|
if (payload.permissions !== undefined) {
|
||||||
|
entry.permissions = normalizePermissions(payload.permissions);
|
||||||
|
if (entry.role !== "custom") entry.role = "custom";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (entry.role === "custom" && payload.permissions === undefined && payload.role !== "custom") {
|
||||||
|
entry.permissions = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.updatedAt = Date.now();
|
||||||
|
writeStore(store);
|
||||||
|
return listUsers(oauth).find((u) => u.id === String(id));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeUser(id, oauth) {
|
||||||
|
if (isYamlOwner(id, oauth)) {
|
||||||
|
throw new Error("Remove this user from OwnerUserIds in api.yml instead");
|
||||||
|
}
|
||||||
|
|
||||||
|
const store = readStore();
|
||||||
|
if (!store.users[String(id)]) throw new Error("User not found");
|
||||||
|
delete store.users[String(id)];
|
||||||
|
writeStore(store);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
CONFIG_FILES,
|
||||||
|
STORE_PATH,
|
||||||
|
getOwnerIds,
|
||||||
|
isYamlOwner,
|
||||||
|
bootstrapFromOAuth,
|
||||||
|
syncDiscordProfile,
|
||||||
|
getAccess,
|
||||||
|
canLogin,
|
||||||
|
formatPublicUser,
|
||||||
|
listUsers,
|
||||||
|
addUser,
|
||||||
|
updateUser,
|
||||||
|
removeUser,
|
||||||
|
hasPermission,
|
||||||
|
canAccessConfig,
|
||||||
|
ROLES,
|
||||||
|
};
|
||||||
+1013
-363
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,233 @@
|
|||||||
|
const crypto = require("crypto");
|
||||||
|
const axios = require("axios");
|
||||||
|
|
||||||
|
const DISCORD_API = "https://discord.com/api";
|
||||||
|
const OAUTH_STATE_COOKIE = "sb_oauth_state";
|
||||||
|
const OAUTH_REDIRECT_COOKIE = "sb_oauth_redirect";
|
||||||
|
const OAUTH_STATE_MAX_AGE_MS = 10 * 60 * 1000;
|
||||||
|
const CALLBACK_PATH = "/api/auth/discord/callback";
|
||||||
|
|
||||||
|
function discordAvatarUrl(user) {
|
||||||
|
if (user.avatar) {
|
||||||
|
const ext = user.avatar.startsWith("a_") ? "gif" : "png";
|
||||||
|
return `https://cdn.discordapp.com/avatars/${user.id}/${user.avatar}.${ext}?size=128`;
|
||||||
|
}
|
||||||
|
const index = Number((BigInt(user.id) >> 22n) % 6n);
|
||||||
|
return `https://cdn.discordapp.com/embed/avatars/${index}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTrustProxySetting(apiConfig) {
|
||||||
|
if (apiConfig?.TrustProxy === false) return false;
|
||||||
|
if (typeof apiConfig?.TrustProxy === "number") return apiConfig.TrustProxy;
|
||||||
|
if (apiConfig?.TrustProxy === true) return 1;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestProto(req, apiConfig) {
|
||||||
|
if (apiConfig?.TrustProxy !== false) {
|
||||||
|
const forwarded = req.get("x-forwarded-proto");
|
||||||
|
if (forwarded) {
|
||||||
|
return forwarded.split(",")[0].trim().toLowerCase();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return (req.protocol || "http").toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestHost(req) {
|
||||||
|
const forwarded = req.get("x-forwarded-host");
|
||||||
|
if (forwarded) {
|
||||||
|
return forwarded.split(",")[0].trim();
|
||||||
|
}
|
||||||
|
return req.get("host") || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRequestOrigin(req, apiConfig) {
|
||||||
|
const proto = getRequestProto(req, apiConfig);
|
||||||
|
const host = getRequestHost(req);
|
||||||
|
if (!host) return null;
|
||||||
|
return `${proto}://${host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRedirectUri(uri) {
|
||||||
|
try {
|
||||||
|
const u = new URL(uri);
|
||||||
|
u.hash = "";
|
||||||
|
u.search = "";
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function redirectUriMatchesHostPath(a, b) {
|
||||||
|
try {
|
||||||
|
const ua = new URL(a);
|
||||||
|
const ub = new URL(b);
|
||||||
|
return ua.host === ub.host && ua.pathname === ub.pathname;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectConfiguredRedirectUris(oauth) {
|
||||||
|
const list = [];
|
||||||
|
if (oauth?.RedirectUri) {
|
||||||
|
const normalized = normalizeRedirectUri(oauth.RedirectUri);
|
||||||
|
if (normalized) list.push(normalized);
|
||||||
|
}
|
||||||
|
if (Array.isArray(oauth?.RedirectUris)) {
|
||||||
|
for (const entry of oauth.RedirectUris) {
|
||||||
|
const normalized = normalizeRedirectUri(entry);
|
||||||
|
if (normalized && !list.includes(normalized)) {
|
||||||
|
list.push(normalized);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Pick the OAuth redirect_uri for this request. When UseRequestOrigin is enabled
|
||||||
|
* (default), uses the same host/path as RedirectUri but the protocol the user
|
||||||
|
* actually used (http vs https). Register every variant in the Discord portal.
|
||||||
|
*/
|
||||||
|
function resolveOAuthRedirectUri(req, oauth, apiConfig) {
|
||||||
|
const configured = collectConfiguredRedirectUris(oauth);
|
||||||
|
const fallback = configured[0] || oauth?.RedirectUri || "";
|
||||||
|
|
||||||
|
const useRequestOrigin = oauth?.UseRequestOrigin !== false;
|
||||||
|
if (!useRequestOrigin || !req) {
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
const origin = getRequestOrigin(req, apiConfig);
|
||||||
|
if (!origin) return fallback;
|
||||||
|
|
||||||
|
const dynamic = `${origin}${CALLBACK_PATH}`;
|
||||||
|
const normalizedDynamic = normalizeRedirectUri(dynamic);
|
||||||
|
if (!normalizedDynamic) return fallback;
|
||||||
|
|
||||||
|
if (configured.includes(normalizedDynamic)) {
|
||||||
|
return normalizedDynamic;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchesConfigured = configured.some((uri) =>
|
||||||
|
redirectUriMatchesHostPath(uri, normalizedDynamic),
|
||||||
|
);
|
||||||
|
if (matchesConfigured) {
|
||||||
|
return normalizedDynamic;
|
||||||
|
}
|
||||||
|
|
||||||
|
return fallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSecureRequest(req, oauth, apiConfig) {
|
||||||
|
if (oauth?.CookieSecure === true) return true;
|
||||||
|
if (oauth?.CookieSecure === false) return false;
|
||||||
|
if (req) {
|
||||||
|
if (typeof req.secure === "boolean") return req.secure;
|
||||||
|
return getRequestProto(req, apiConfig) === "https";
|
||||||
|
}
|
||||||
|
return process.env.NODE_ENV === "production";
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildAuthorizeUrl(oauth, state, redirectUri) {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
client_id: oauth.ClientId,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
response_type: "code",
|
||||||
|
scope: "identify",
|
||||||
|
state,
|
||||||
|
prompt: "none",
|
||||||
|
});
|
||||||
|
return `${DISCORD_API}/oauth2/authorize?${params.toString()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exchangeCode(oauth, code, redirectUri) {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
client_id: oauth.ClientId,
|
||||||
|
client_secret: oauth.ClientSecret,
|
||||||
|
grant_type: "authorization_code",
|
||||||
|
code,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data } = await axios.post(`${DISCORD_API}/oauth2/token`, body, {
|
||||||
|
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchDiscordUser(accessToken) {
|
||||||
|
const { data } = await axios.get(`${DISCORD_API}/users/@me`, {
|
||||||
|
headers: { Authorization: `Bearer ${accessToken}` },
|
||||||
|
timeout: 15000,
|
||||||
|
});
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createOAuthState() {
|
||||||
|
return crypto.randomBytes(24).toString("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
function oauthStateCookieOptions(req, oauth, apiConfig) {
|
||||||
|
return {
|
||||||
|
httpOnly: true,
|
||||||
|
secure: isSecureRequest(req, oauth, apiConfig),
|
||||||
|
sameSite: "lax",
|
||||||
|
maxAge: OAUTH_STATE_MAX_AGE_MS,
|
||||||
|
path: "/",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function oauthRedirectCookieOptions(req, oauth, apiConfig) {
|
||||||
|
return oauthStateCookieOptions(req, oauth, apiConfig);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isOAuthConfigured(oauth) {
|
||||||
|
return Boolean(
|
||||||
|
oauth?.Enabled &&
|
||||||
|
oauth.ClientId &&
|
||||||
|
oauth.ClientSecret &&
|
||||||
|
oauth.RedirectUri,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isUserAllowed(userId, oauth) {
|
||||||
|
const allowed = oauth?.AllowedUserIds;
|
||||||
|
if (!Array.isArray(allowed) || allowed.length === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return allowed.map(String).includes(String(userId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPublicUser(user) {
|
||||||
|
return {
|
||||||
|
id: user.id,
|
||||||
|
username: user.username,
|
||||||
|
globalName: user.global_name || null,
|
||||||
|
avatar: discordAvatarUrl(user),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
OAUTH_STATE_COOKIE,
|
||||||
|
OAUTH_REDIRECT_COOKIE,
|
||||||
|
OAUTH_STATE_MAX_AGE_MS,
|
||||||
|
CALLBACK_PATH,
|
||||||
|
getTrustProxySetting,
|
||||||
|
resolveOAuthRedirectUri,
|
||||||
|
isSecureRequest,
|
||||||
|
buildAuthorizeUrl,
|
||||||
|
exchangeCode,
|
||||||
|
fetchDiscordUser,
|
||||||
|
createOAuthState,
|
||||||
|
oauthStateCookieOptions,
|
||||||
|
oauthRedirectCookieOptions,
|
||||||
|
isOAuthConfigured,
|
||||||
|
isUserAllowed,
|
||||||
|
formatPublicUser,
|
||||||
|
discordAvatarUrl,
|
||||||
|
};
|
||||||
@@ -0,0 +1,277 @@
|
|||||||
|
const { ChannelType } = require("discord.js");
|
||||||
|
const configStore = require("./ConfigStore.js");
|
||||||
|
const { withTimeout } = require("./asyncTimeout.js");
|
||||||
|
|
||||||
|
const RESOURCES_CACHE_MS = 90 * 1000;
|
||||||
|
/** Bump when resource payload shape changes (e.g. added emojis). */
|
||||||
|
const RESOURCES_CACHE_VERSION = 2;
|
||||||
|
let resourcesCache = { guildId: null, data: null, at: 0, version: 0 };
|
||||||
|
let resourcesInflight = null;
|
||||||
|
|
||||||
|
function getConfiguredGuildId() {
|
||||||
|
const id = configStore.supportbot?.General?.GuildId;
|
||||||
|
if (!id || id === "GUILD_ID" || String(id).trim() === "") return null;
|
||||||
|
return String(id).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setConfiguredGuildId(guildId) {
|
||||||
|
if (!configStore.supportbot.General) {
|
||||||
|
configStore.supportbot.General = {};
|
||||||
|
}
|
||||||
|
configStore.supportbot.General.GuildId = guildId ? String(guildId) : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryGuild(client) {
|
||||||
|
if (!client) return null;
|
||||||
|
|
||||||
|
const configuredId = getConfiguredGuildId();
|
||||||
|
if (configuredId) {
|
||||||
|
const match = client.guilds.cache.get(configuredId);
|
||||||
|
if (match) return match;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client.guilds.cache.first() || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateGuildHealth(client) {
|
||||||
|
const configuredId = getConfiguredGuildId();
|
||||||
|
const guilds = [...client.guilds.cache.values()];
|
||||||
|
|
||||||
|
if (!configuredId) {
|
||||||
|
return {
|
||||||
|
status: "missing_config",
|
||||||
|
configuredGuildId: null,
|
||||||
|
message:
|
||||||
|
"No guild ID is set. Add your Discord server ID under Bot config → General.",
|
||||||
|
configuredGuild: null,
|
||||||
|
extraGuilds: guilds.map(formatGuildSummary),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredGuild = client.guilds.cache.get(configuredId) || null;
|
||||||
|
const extraGuilds = guilds
|
||||||
|
.filter((g) => g.id !== configuredId)
|
||||||
|
.map(formatGuildSummary);
|
||||||
|
|
||||||
|
if (!configuredGuild) {
|
||||||
|
return {
|
||||||
|
status: "not_in_guild",
|
||||||
|
configuredGuildId: configuredId,
|
||||||
|
message: `The bot is not in the configured server (${configuredId}). Invite it or update the guild ID.`,
|
||||||
|
configuredGuild: null,
|
||||||
|
extraGuilds: guilds.map(formatGuildSummary),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (extraGuilds.length > 0) {
|
||||||
|
return {
|
||||||
|
status: "extra_guilds",
|
||||||
|
configuredGuildId: configuredId,
|
||||||
|
message: `The bot is in ${extraGuilds.length} extra server(s). Leave them so only your configured server remains.`,
|
||||||
|
configuredGuild: formatGuildSummary(configuredGuild),
|
||||||
|
extraGuilds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "ok",
|
||||||
|
configuredGuildId: configuredId,
|
||||||
|
message: null,
|
||||||
|
configuredGuild: formatGuildSummary(configuredGuild),
|
||||||
|
extraGuilds: [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGuildSummary(guild) {
|
||||||
|
return {
|
||||||
|
id: guild.id,
|
||||||
|
name: guild.name,
|
||||||
|
memberCount: guild.memberCount ?? null,
|
||||||
|
icon: guild.iconURL?.({ size: 128 }) ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncGuildHealth(client) {
|
||||||
|
if (!client?.user) {
|
||||||
|
client.guildHealth = {
|
||||||
|
status: "offline",
|
||||||
|
configuredGuildId: getConfiguredGuildId(),
|
||||||
|
message: "Bot is not connected to Discord.",
|
||||||
|
configuredGuild: null,
|
||||||
|
extraGuilds: [],
|
||||||
|
};
|
||||||
|
return client.guildHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
client.guildHealth = evaluateGuildHealth(client);
|
||||||
|
return client.guildHealth;
|
||||||
|
}
|
||||||
|
|
||||||
|
function logGuildWarning(client, message) {
|
||||||
|
const name = client?.user?.username || "Bot";
|
||||||
|
console.warn(`\x1b[33m[Guild]\x1b[0m ${name}: ${message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function leaveGuild(client, guildId) {
|
||||||
|
const guild = client.guilds.cache.get(String(guildId));
|
||||||
|
if (!guild) {
|
||||||
|
throw new Error("Bot is not in that server.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const configuredId = getConfiguredGuildId();
|
||||||
|
if (configuredId && guild.id === configuredId) {
|
||||||
|
throw new Error("Cannot leave the configured server. Change the guild ID first.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await guild.leave();
|
||||||
|
syncGuildHealth(client);
|
||||||
|
return syncGuildHealth(client);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildResourcesFromGuild(guild) {
|
||||||
|
const roles = [...guild.roles.cache.values()]
|
||||||
|
.filter((r) => r.id !== guild.id)
|
||||||
|
.sort((a, b) => b.position - a.position)
|
||||||
|
.map((r) => ({
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
color: r.hexColor || null,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const channels = [...guild.channels.cache.values()]
|
||||||
|
.filter(
|
||||||
|
(c) =>
|
||||||
|
c.type === ChannelType.GuildText ||
|
||||||
|
c.type === ChannelType.GuildVoice ||
|
||||||
|
c.type === ChannelType.GuildAnnouncement ||
|
||||||
|
c.type === ChannelType.GuildStageVoice ||
|
||||||
|
c.type === ChannelType.GuildForum,
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
type: c.type,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const categories = [...guild.channels.cache.values()]
|
||||||
|
.filter((c) => c.type === ChannelType.GuildCategory)
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((c) => ({
|
||||||
|
id: c.id,
|
||||||
|
name: c.name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const emojis = [...guild.emojis.cache.values()]
|
||||||
|
.sort((a, b) => a.name.localeCompare(b.name))
|
||||||
|
.map((e) => {
|
||||||
|
const animated = Boolean(e.animated);
|
||||||
|
const ext = animated ? "gif" : "png";
|
||||||
|
return {
|
||||||
|
id: e.id,
|
||||||
|
name: e.name,
|
||||||
|
animated,
|
||||||
|
url:
|
||||||
|
e.imageURL({ extension: ext, size: 64 }) ||
|
||||||
|
`https://cdn.discordapp.com/emojis/${e.id}.${ext}?size=64`,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
guildId: guild.id,
|
||||||
|
guildName: guild.name,
|
||||||
|
guildIcon: guild.iconURL({ extension: "png", size: 64 }) || null,
|
||||||
|
roles,
|
||||||
|
channels,
|
||||||
|
categories,
|
||||||
|
emojis,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGuildResourcesFromDiscord(client, guildId) {
|
||||||
|
const id = guildId || getConfiguredGuildId();
|
||||||
|
if (!id) return null;
|
||||||
|
|
||||||
|
const guild = client.guilds.cache.get(id);
|
||||||
|
if (!guild) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withTimeout(
|
||||||
|
Promise.all([guild.channels.fetch(), guild.roles.fetch()]),
|
||||||
|
12_000,
|
||||||
|
"Discord channel/role fetch timed out",
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[Guild] Failed to fetch channels/roles:", err.message);
|
||||||
|
if (guild.roles.cache.size === 0 && guild.channels.cache.size === 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await withTimeout(guild.emojis.fetch(), 12_000, "Discord emoji fetch timed out");
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[Guild] Failed to fetch guild emojis:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildResourcesFromGuild(guild);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchGuildResources(client, guildId) {
|
||||||
|
const id = guildId || getConfiguredGuildId();
|
||||||
|
if (!id || !client?.user) return null;
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (
|
||||||
|
resourcesCache.data &&
|
||||||
|
resourcesCache.version === RESOURCES_CACHE_VERSION &&
|
||||||
|
resourcesCache.guildId === id &&
|
||||||
|
now - resourcesCache.at < RESOURCES_CACHE_MS
|
||||||
|
) {
|
||||||
|
return resourcesCache.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (resourcesInflight) {
|
||||||
|
try {
|
||||||
|
return await resourcesInflight;
|
||||||
|
} catch {
|
||||||
|
/* fall through to retry */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
resourcesInflight = (async () => {
|
||||||
|
const data = await fetchGuildResourcesFromDiscord(client, id);
|
||||||
|
if (data) {
|
||||||
|
resourcesCache = {
|
||||||
|
guildId: id,
|
||||||
|
data,
|
||||||
|
at: Date.now(),
|
||||||
|
version: RESOURCES_CACHE_VERSION,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
})();
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await resourcesInflight;
|
||||||
|
} finally {
|
||||||
|
resourcesInflight = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearGuildResourcesCache() {
|
||||||
|
resourcesCache = { guildId: null, data: null, at: 0, version: 0 };
|
||||||
|
resourcesInflight = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getConfiguredGuildId,
|
||||||
|
setConfiguredGuildId,
|
||||||
|
getPrimaryGuild,
|
||||||
|
evaluateGuildHealth,
|
||||||
|
syncGuildHealth,
|
||||||
|
logGuildWarning,
|
||||||
|
leaveGuild,
|
||||||
|
fetchGuildResources,
|
||||||
|
clearGuildResourcesCache,
|
||||||
|
};
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const LOG_ROOT = path.resolve(__dirname, "../Logs");
|
||||||
|
const LOG_TYPES = ["Output", "Warn", "Error"];
|
||||||
|
|
||||||
|
function todayDate() {
|
||||||
|
return new Date().toISOString().split("T")[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogPath(type, date = todayDate()) {
|
||||||
|
return path.join(LOG_ROOT, type, `${type}-${date}.log`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Prefer today's file; fall back to the newest log in the folder. */
|
||||||
|
function resolveLogPath(type) {
|
||||||
|
const todayPath = getLogPath(type);
|
||||||
|
if (fs.existsSync(todayPath)) return todayPath;
|
||||||
|
|
||||||
|
const dir = path.join(LOG_ROOT, type);
|
||||||
|
if (!fs.existsSync(dir)) return todayPath;
|
||||||
|
|
||||||
|
const files = fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith(".log"))
|
||||||
|
.sort()
|
||||||
|
.reverse();
|
||||||
|
|
||||||
|
if (!files.length) return todayPath;
|
||||||
|
return path.join(dir, files[0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stripAnsi(text) {
|
||||||
|
return String(text).replace(/\x1b\[[0-9;]*m/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseLogLine(line, type) {
|
||||||
|
const trimmed = line.trimEnd();
|
||||||
|
if (!trimmed) return null;
|
||||||
|
|
||||||
|
const match = trimmed.match(/^\[([^\]]+)\]\s?(.*)$/);
|
||||||
|
if (match) {
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
timestamp: match[1],
|
||||||
|
message: stripAnsi(match[2]),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
timestamp: null,
|
||||||
|
message: stripAnsi(trimmed),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function readFromByte(filePath, fromByte) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return { lines: [], position: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (stat.size === 0) {
|
||||||
|
return { lines: [], position: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const start = Math.max(0, Math.min(fromByte, stat.size));
|
||||||
|
if (start >= stat.size) {
|
||||||
|
return { lines: [], position: stat.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
const fd = fs.openSync(filePath, "r");
|
||||||
|
const buffer = Buffer.alloc(stat.size - start);
|
||||||
|
fs.readSync(fd, buffer, 0, buffer.length, start);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
|
||||||
|
const text = buffer.toString("utf8");
|
||||||
|
const lines = text.split(/\r?\n/).filter((l) => l.length > 0);
|
||||||
|
|
||||||
|
return { lines, position: stat.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLastLines(filePath, maxLines) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return { lines: [], position: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const stat = fs.statSync(filePath);
|
||||||
|
if (stat.size === 0) {
|
||||||
|
return { lines: [], position: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const chunkSize = Math.min(stat.size, 512 * 1024);
|
||||||
|
const start = Math.max(0, stat.size - chunkSize);
|
||||||
|
const fd = fs.openSync(filePath, "r");
|
||||||
|
const buffer = Buffer.alloc(chunkSize);
|
||||||
|
fs.readSync(fd, buffer, 0, chunkSize, start);
|
||||||
|
fs.closeSync(fd);
|
||||||
|
|
||||||
|
const lines = buffer
|
||||||
|
.toString("utf8")
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.filter((l) => l.length > 0);
|
||||||
|
const slice = lines.slice(-maxLines);
|
||||||
|
|
||||||
|
return { lines: slice, position: stat.size };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {string[]} types
|
||||||
|
* @param {Record<string, number>} cursor byte offsets per type
|
||||||
|
* @param {number} initialLines lines to load when cursor is empty
|
||||||
|
*/
|
||||||
|
function fetchLogs(types, cursor = {}, initialLines = 300) {
|
||||||
|
const validTypes = types.filter((t) => LOG_TYPES.includes(t));
|
||||||
|
const entries = [];
|
||||||
|
const nextCursor = { ...cursor };
|
||||||
|
|
||||||
|
for (const type of validTypes) {
|
||||||
|
const filePath = resolveLogPath(type);
|
||||||
|
const prevPos = cursor[type] ?? 0;
|
||||||
|
let lines = [];
|
||||||
|
let position = 0;
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
nextCursor[type] = 0;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (prevPos === 0 && initialLines > 0) {
|
||||||
|
const tail = readLastLines(filePath, initialLines);
|
||||||
|
lines = tail.lines;
|
||||||
|
position = tail.position;
|
||||||
|
} else if (prevPos > 0) {
|
||||||
|
const chunk = readFromByte(filePath, prevPos);
|
||||||
|
lines = chunk.lines;
|
||||||
|
position = chunk.position;
|
||||||
|
} else {
|
||||||
|
// No tail requested and no cursor — skip content, anchor at EOF
|
||||||
|
position = fs.statSync(filePath).size;
|
||||||
|
}
|
||||||
|
|
||||||
|
nextCursor[type] = position;
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
const entry = parseLogLine(line, type);
|
||||||
|
if (entry) entries.push(entry);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entries.sort((a, b) => {
|
||||||
|
const ta = a.timestamp ? Date.parse(a.timestamp) : 0;
|
||||||
|
const tb = b.timestamp ? Date.parse(b.timestamp) : 0;
|
||||||
|
return ta - tb;
|
||||||
|
});
|
||||||
|
|
||||||
|
return { entries, cursor: nextCursor };
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLogMeta() {
|
||||||
|
const date = todayDate();
|
||||||
|
return LOG_TYPES.map((type) => {
|
||||||
|
const filePath = resolveLogPath(type);
|
||||||
|
const exists = fs.existsSync(filePath) && fs.statSync(filePath).size > 0;
|
||||||
|
return {
|
||||||
|
type,
|
||||||
|
date,
|
||||||
|
file: exists ? path.basename(filePath) : null,
|
||||||
|
size: exists ? fs.statSync(filePath).size : 0,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
LOG_ROOT,
|
||||||
|
LOG_TYPES,
|
||||||
|
fetchLogs,
|
||||||
|
getLogMeta,
|
||||||
|
getLogPath,
|
||||||
|
resolveLogPath,
|
||||||
|
};
|
||||||
@@ -0,0 +1,270 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const PLACEHOLDER_PATTERNS = [
|
||||||
|
/ROLE_ID/i,
|
||||||
|
/CHANNEL_ID/i,
|
||||||
|
/CATEGORY_ID/i,
|
||||||
|
/TAG_ID/i,
|
||||||
|
/FORUM_CHANNEL_ID/i,
|
||||||
|
/BOT_TOKEN/i,
|
||||||
|
/MODEL_API_KEY/i,
|
||||||
|
/PASTEBIN_API_KEY/i,
|
||||||
|
/YOUR_/i,
|
||||||
|
/PUT_RANDOM_STRING/i,
|
||||||
|
/example\.com/i,
|
||||||
|
];
|
||||||
|
|
||||||
|
function isEmpty(value) {
|
||||||
|
if (value == null) return true;
|
||||||
|
if (typeof value === "boolean") return false;
|
||||||
|
if (typeof value === "number") return false;
|
||||||
|
return String(value).trim() === "";
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPlaceholder(value) {
|
||||||
|
if (isEmpty(value)) return true;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return PLACEHOLDER_PATTERNS.some((p) => p.test(s));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDiscordId(value) {
|
||||||
|
return /^\d{17,20}$/.test(String(value).trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isConfiguredId(value) {
|
||||||
|
return isDiscordId(value) && !isPlaceholder(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidToken(value) {
|
||||||
|
if (isPlaceholder(value)) return false;
|
||||||
|
const s = String(value).trim();
|
||||||
|
return s.length >= 50 && s.includes(".");
|
||||||
|
}
|
||||||
|
|
||||||
|
function scoreChecks(checks) {
|
||||||
|
const total = checks.length;
|
||||||
|
const passed = checks.filter((c) => c.ok).length;
|
||||||
|
|
||||||
|
if (total === 0) {
|
||||||
|
return { status: "done", subtitle: "Ready", passed: 0, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passed === 0) {
|
||||||
|
const hint = checks.find((c) => !c.ok)?.hint ?? "Not configured yet";
|
||||||
|
return { status: "todo", subtitle: hint, passed, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (passed === total) {
|
||||||
|
return { status: "done", subtitle: "Configured and ready", passed, total };
|
||||||
|
}
|
||||||
|
|
||||||
|
const next = checks.find((c) => !c.ok);
|
||||||
|
return {
|
||||||
|
status: "progress",
|
||||||
|
subtitle: next?.hint ?? `${passed} of ${total} steps complete`,
|
||||||
|
passed,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateTickets(supportbot, ticketPanel) {
|
||||||
|
const ticket = supportbot?.Ticket ?? {};
|
||||||
|
const ticketType = String(ticket.TicketType || "channels").toLowerCase();
|
||||||
|
|
||||||
|
return scoreChecks([
|
||||||
|
{
|
||||||
|
ok: ticketPanel?.Panel === true,
|
||||||
|
hint: "Enable the ticket panel",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: isConfiguredId(ticket.TicketHome),
|
||||||
|
hint: "Set TicketHome channel ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok:
|
||||||
|
ticketType !== "channels" ||
|
||||||
|
isConfiguredId(ticket.TicketChannelsCategory),
|
||||||
|
hint: "Set ticket category ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: !isPlaceholder(ticketPanel?.Button?.Text),
|
||||||
|
hint: "Configure panel button text",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateSupportbot(supportbot) {
|
||||||
|
const roles = supportbot?.Roles?.StaffMember ?? {};
|
||||||
|
const welcome = supportbot?.Welcome ?? {};
|
||||||
|
|
||||||
|
return scoreChecks([
|
||||||
|
{ ok: isValidToken(supportbot?.General?.Token), hint: "Add your bot token" },
|
||||||
|
{
|
||||||
|
ok:
|
||||||
|
isConfiguredId(roles.Staff) ||
|
||||||
|
isConfiguredId(roles.Admin) ||
|
||||||
|
isConfiguredId(roles.Moderator),
|
||||||
|
hint: "Set staff role IDs",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: !welcome.Enabled || isConfiguredId(welcome.Channel),
|
||||||
|
hint: "Set welcome channel ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: !isPlaceholder(supportbot?.Activity?.Status),
|
||||||
|
hint: "Set bot activity status",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateTranscripts(supportbot, transcriptCount) {
|
||||||
|
const ticketReady = isConfiguredId(supportbot?.Ticket?.TicketHome);
|
||||||
|
|
||||||
|
if (!ticketReady) {
|
||||||
|
// Ticket setup is tracked by the tickets module — avoid a misleading transcripts alert.
|
||||||
|
return {
|
||||||
|
status: "done",
|
||||||
|
subtitle: "Available after ticket channels are configured",
|
||||||
|
passed: 0,
|
||||||
|
total: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (transcriptCount > 0) {
|
||||||
|
return {
|
||||||
|
status: "done",
|
||||||
|
subtitle: `${transcriptCount} transcript${transcriptCount === 1 ? "" : "s"} saved`,
|
||||||
|
passed: 1,
|
||||||
|
total: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "done",
|
||||||
|
subtitle: "No transcripts yet — they are created when tickets close",
|
||||||
|
passed: 1,
|
||||||
|
total: 1,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateSuggestions(supportbot) {
|
||||||
|
const sug = supportbot?.Suggestions ?? {};
|
||||||
|
const mode = String(sug.Mode || "Channel").toLowerCase();
|
||||||
|
|
||||||
|
const channelOk =
|
||||||
|
mode === "forum"
|
||||||
|
? isConfiguredId(sug.ForumChannel)
|
||||||
|
: isConfiguredId(sug.Channel);
|
||||||
|
|
||||||
|
return scoreChecks([
|
||||||
|
{
|
||||||
|
ok: channelOk,
|
||||||
|
hint:
|
||||||
|
mode === "forum"
|
||||||
|
? "Set suggestions forum channel"
|
||||||
|
: "Set suggestions channel ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: !isPlaceholder(sug.UpvoteEmoji) && !isPlaceholder(sug.DownvoteEmoji),
|
||||||
|
hint: "Configure voting emojis",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateAi(supportbotAi) {
|
||||||
|
if (supportbotAi?.Enabled !== true) {
|
||||||
|
return {
|
||||||
|
status: "progress",
|
||||||
|
subtitle: "AI is disabled in config",
|
||||||
|
passed: 0,
|
||||||
|
total: 3,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return scoreChecks([
|
||||||
|
{
|
||||||
|
ok: !isPlaceholder(supportbotAi?.General?.Model_API_Key),
|
||||||
|
hint: "Add your model API key",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: isConfiguredId(supportbotAi?.Channels?.AIChannel),
|
||||||
|
hint: "Set AI channel ID",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ok: !isPlaceholder(supportbotAi?.General?.Model),
|
||||||
|
hint: "Choose an AI model",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateCommands(commands) {
|
||||||
|
const entries = Object.entries(commands || {}).filter(
|
||||||
|
([, v]) => v && typeof v === "object" && "Enabled" in v,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!entries.length) {
|
||||||
|
return { status: "todo", subtitle: "No commands configured", passed: 0, total: 0 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const enabled = entries.filter(([, v]) => v.Enabled !== false).length;
|
||||||
|
const total = entries.length;
|
||||||
|
const ratio = enabled / total;
|
||||||
|
|
||||||
|
if (ratio >= 0.85) {
|
||||||
|
return {
|
||||||
|
status: "done",
|
||||||
|
subtitle: `${enabled} of ${total} commands enabled`,
|
||||||
|
passed: enabled,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (enabled === 0) {
|
||||||
|
return {
|
||||||
|
status: "todo",
|
||||||
|
subtitle: "Enable slash commands",
|
||||||
|
passed: 0,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: "progress",
|
||||||
|
subtitle: `${enabled} of ${total} commands enabled`,
|
||||||
|
passed: enabled,
|
||||||
|
total,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function countTranscripts() {
|
||||||
|
const dir = path.resolve(__dirname, "../Data/Transcripts");
|
||||||
|
if (!fs.existsSync(dir)) return 0;
|
||||||
|
return fs
|
||||||
|
.readdirSync(dir)
|
||||||
|
.filter((f) => f.endsWith("-transcript.html")).length;
|
||||||
|
}
|
||||||
|
|
||||||
|
function evaluateAllModules(configStore, options = {}) {
|
||||||
|
const supportbot = configStore.supportbot;
|
||||||
|
const ticketPanel = configStore.ticketPanel;
|
||||||
|
const supportbotAi = configStore.supportbotAi;
|
||||||
|
const commands = configStore.commands;
|
||||||
|
const transcriptCount =
|
||||||
|
options.transcriptCount ?? countTranscripts();
|
||||||
|
|
||||||
|
return {
|
||||||
|
tickets: evaluateTickets(supportbot, ticketPanel),
|
||||||
|
supportbot: evaluateSupportbot(supportbot),
|
||||||
|
transcripts: evaluateTranscripts(supportbot, transcriptCount),
|
||||||
|
suggestions: evaluateSuggestions(supportbot),
|
||||||
|
ai: evaluateAi(supportbotAi),
|
||||||
|
commands: evaluateCommands(commands),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
evaluateAllModules,
|
||||||
|
isPlaceholder,
|
||||||
|
isConfiguredId,
|
||||||
|
};
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
const notificationStore = require("./NotificationStore.js");
|
||||||
|
const { fetchUpdateInfo } = require("./UpdateChecker.js");
|
||||||
|
|
||||||
|
function notifyBotRestart(success, errorMessage) {
|
||||||
|
if (success) {
|
||||||
|
return notificationStore.add({
|
||||||
|
type: "bot",
|
||||||
|
title: "Bot restarted",
|
||||||
|
message:
|
||||||
|
"Discord bot reconnected with your latest config. API and dashboard stayed online.",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return notificationStore.add({
|
||||||
|
type: "error",
|
||||||
|
title: "Bot failed to restart",
|
||||||
|
message: errorMessage || "Check the logs for details.",
|
||||||
|
href: "/logs",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyUpdateAvailable(info) {
|
||||||
|
return notificationStore.add({
|
||||||
|
id: "update-available",
|
||||||
|
type: "update",
|
||||||
|
title: "Update available",
|
||||||
|
message: `Version ${info.latest} is ready (you are on ${info.current}).`,
|
||||||
|
href: "/settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function notifyUpdateInstalled(message) {
|
||||||
|
return notificationStore.add({
|
||||||
|
type: "update",
|
||||||
|
title: "Update installed",
|
||||||
|
message:
|
||||||
|
message ||
|
||||||
|
"Files were updated. Restart your server (npm start) to finish applying changes.",
|
||||||
|
href: "/settings",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUpdateAvailable() {
|
||||||
|
notificationStore.dismiss("update-available");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function runUpdateCheck() {
|
||||||
|
const info = await fetchUpdateInfo();
|
||||||
|
if (info.updateAvailable) {
|
||||||
|
notifyUpdateAvailable(info);
|
||||||
|
} else {
|
||||||
|
clearUpdateAvailable();
|
||||||
|
}
|
||||||
|
return info;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
notifyBotRestart,
|
||||||
|
notifyUpdateAvailable,
|
||||||
|
notifyUpdateInstalled,
|
||||||
|
clearUpdateAvailable,
|
||||||
|
runUpdateCheck,
|
||||||
|
};
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
const MAX_NOTIFICATIONS = 50;
|
||||||
|
|
||||||
|
/** @type {Map<string, object>} */
|
||||||
|
const byId = new Map();
|
||||||
|
/** @type {string[]} */
|
||||||
|
let order = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param {object} input
|
||||||
|
* @param {string} [input.id] Stable id replaces existing (e.g. update-available)
|
||||||
|
*/
|
||||||
|
function add(input) {
|
||||||
|
const id =
|
||||||
|
input.id ||
|
||||||
|
`n-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
|
||||||
|
|
||||||
|
const item = {
|
||||||
|
id,
|
||||||
|
type: input.type || "info",
|
||||||
|
title: input.title || "Notification",
|
||||||
|
message: input.message || "",
|
||||||
|
href: input.href || null,
|
||||||
|
createdAt: input.createdAt || Date.now(),
|
||||||
|
read: false,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (byId.has(id)) {
|
||||||
|
order = order.filter((x) => x !== id);
|
||||||
|
}
|
||||||
|
|
||||||
|
byId.set(id, item);
|
||||||
|
order.unshift(id);
|
||||||
|
|
||||||
|
while (order.length > MAX_NOTIFICATIONS) {
|
||||||
|
const removed = order.pop();
|
||||||
|
if (removed) byId.delete(removed);
|
||||||
|
}
|
||||||
|
|
||||||
|
return item;
|
||||||
|
}
|
||||||
|
|
||||||
|
function list() {
|
||||||
|
return order.map((id) => byId.get(id)).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function unreadCount() {
|
||||||
|
let n = 0;
|
||||||
|
for (const item of byId.values()) {
|
||||||
|
if (!item.read) n++;
|
||||||
|
}
|
||||||
|
return n;
|
||||||
|
}
|
||||||
|
|
||||||
|
function markRead(ids) {
|
||||||
|
if (ids === "all") {
|
||||||
|
for (const item of byId.values()) {
|
||||||
|
item.read = true;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const id of ids) {
|
||||||
|
const item = byId.get(id);
|
||||||
|
if (item) item.read = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function dismiss(id) {
|
||||||
|
if (!byId.has(id)) return false;
|
||||||
|
byId.delete(id);
|
||||||
|
order = order.filter((x) => x !== id);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clear() {
|
||||||
|
byId.clear();
|
||||||
|
order = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
add,
|
||||||
|
list,
|
||||||
|
unreadCount,
|
||||||
|
markRead,
|
||||||
|
dismiss,
|
||||||
|
clear,
|
||||||
|
};
|
||||||
+28
-12
@@ -1,30 +1,46 @@
|
|||||||
// SupportBot | Emerald Services
|
// SupportBot | Emerald Services
|
||||||
// Ticket ID Structure
|
// Ticket ID Structure
|
||||||
|
|
||||||
const db = require("./Database")
|
const db = require("./Database");
|
||||||
|
|
||||||
class TicketNumberID {
|
class TicketNumberID {
|
||||||
static async pad() {
|
static async pad() {
|
||||||
let num = await this.get()
|
let num = await this.get();
|
||||||
let n = num + ""
|
let n = num + "";
|
||||||
await this.set(num + 1)
|
await this.set(num + 1);
|
||||||
return n.length >= 4 ? n : new Array(4 - n.length + 1).join("0") + n
|
return n.length >= 4 ? n : new Array(4 - n.length + 1).join("0") + n;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async get() {
|
static async get() {
|
||||||
const row = db.getTicketPanel()
|
const settings = db.getSettings ? db.getSettings() : {};
|
||||||
if (!row) return await this.set(0)
|
if (typeof settings.ticket_counter === "number") {
|
||||||
return row.id
|
return settings.ticket_counter;
|
||||||
|
}
|
||||||
|
|
||||||
|
const row = db.getTicketPanel ? db.getTicketPanel() : null;
|
||||||
|
if (row && typeof row.message_id === "string" && row.message_id.startsWith("panel-")) {
|
||||||
|
const parsed = parseInt(row.message_id.replace("panel-", ""), 10);
|
||||||
|
if (!isNaN(parsed)) return parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row && typeof row.id === "number" && row.id < 1000000) {
|
||||||
|
return row.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async set(value) {
|
static async set(value) {
|
||||||
db.saveTicketPanel(`panel-${value}`, "none")
|
const settings = db.getSettings ? db.getSettings() : {};
|
||||||
return value
|
settings.ticket_counter = value;
|
||||||
|
if (db.saveSettings) db.saveSettings(settings);
|
||||||
|
if (db.saveTicketPanel) db.saveTicketPanel(`panel-${value}`, "none");
|
||||||
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
static async reset() {
|
static async reset() {
|
||||||
return await this.set(0)
|
return await this.set(0);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TicketNumberID
|
module.exports = TicketNumberID;
|
||||||
+58
-401
@@ -4,8 +4,9 @@ const Discord = require("discord.js");
|
|||||||
const yaml = require("js-yaml");
|
const yaml = require("js-yaml");
|
||||||
const db = require("./Database.js");
|
const db = require("./Database.js");
|
||||||
|
|
||||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
const supportbot = require("./ConfigStore").supportbot;
|
||||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
const msgconfig = require("./ConfigStore").messages;
|
||||||
|
const { createTranscriptHTML } = require("./TranscriptTemplate.js");
|
||||||
|
|
||||||
function escapeHtml(str = "") {
|
function escapeHtml(str = "") {
|
||||||
return String(str)
|
return String(str)
|
||||||
@@ -68,7 +69,7 @@ function renderAttachment(att) {
|
|||||||
if (type === "image") {
|
if (type === "image") {
|
||||||
return `
|
return `
|
||||||
<div class="attachment">
|
<div class="attachment">
|
||||||
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
||||||
<div class="attachment-preview attachment-image">
|
<div class="attachment-preview attachment-image">
|
||||||
<img src="${url}" alt="${name}" loading="lazy">
|
<img src="${url}" alt="${name}" loading="lazy">
|
||||||
</div>
|
</div>
|
||||||
@@ -79,7 +80,7 @@ function renderAttachment(att) {
|
|||||||
if (type === "video") {
|
if (type === "video") {
|
||||||
return `
|
return `
|
||||||
<div class="attachment">
|
<div class="attachment">
|
||||||
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
||||||
<div class="attachment-preview">
|
<div class="attachment-preview">
|
||||||
<video controls preload="metadata">
|
<video controls preload="metadata">
|
||||||
<source src="${url}">
|
<source src="${url}">
|
||||||
@@ -92,7 +93,7 @@ function renderAttachment(att) {
|
|||||||
if (type === "audio") {
|
if (type === "audio") {
|
||||||
return `
|
return `
|
||||||
<div class="attachment">
|
<div class="attachment">
|
||||||
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
||||||
<div class="attachment-preview">
|
<div class="attachment-preview">
|
||||||
<audio controls preload="metadata">
|
<audio controls preload="metadata">
|
||||||
<source src="${url}">
|
<source src="${url}">
|
||||||
@@ -104,7 +105,7 @@ function renderAttachment(att) {
|
|||||||
|
|
||||||
return `
|
return `
|
||||||
<div class="attachment">
|
<div class="attachment">
|
||||||
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
}
|
}
|
||||||
@@ -134,394 +135,14 @@ function renderEmbeds(embeds = []) {
|
|||||||
.join("");
|
.join("");
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTranscriptHTML(ticket, reason) {
|
async function createTranscript(interaction, ticketInput, reasonInput) {
|
||||||
return `<!DOCTYPE html>
|
let ticketRecord = typeof ticketInput === "object" && ticketInput !== null ? ticketInput : null;
|
||||||
<html lang="en">
|
let closeReason = typeof ticketInput === "string" ? ticketInput : reasonInput;
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Transcript - ${escapeHtml(ticket.name)}</title>
|
|
||||||
<style>
|
|
||||||
:root {
|
|
||||||
--bg: #0b0f14;
|
|
||||||
--panel: #111821;
|
|
||||||
--panel-2: #182230;
|
|
||||||
--panel-3: #1d2938;
|
|
||||||
--border: #283548;
|
|
||||||
--text: #e6edf3;
|
|
||||||
--muted: #9fb0c3;
|
|
||||||
--accent: #22c55e;
|
|
||||||
--accent-soft: rgba(34, 197, 94, 0.12);
|
|
||||||
--code: #0f1720;
|
|
||||||
}
|
|
||||||
|
|
||||||
* {
|
if (!ticketRecord && typeof db.getTicket === "function") {
|
||||||
box-sizing: border-box;
|
ticketRecord = db.getTicket(interaction.channel.id) || {};
|
||||||
}
|
}
|
||||||
|
|
||||||
html, body {
|
|
||||||
margin: 0;
|
|
||||||
padding: 0;
|
|
||||||
background: var(--bg);
|
|
||||||
color: var(--text);
|
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
body {
|
|
||||||
padding: 32px 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page {
|
|
||||||
max-width: 1100px;
|
|
||||||
margin: 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
padding: 24px;
|
|
||||||
margin-bottom: 18px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header-top {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: flex-start;
|
|
||||||
gap: 16px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-wrap h1 {
|
|
||||||
margin: 0;
|
|
||||||
font-size: 1.8rem;
|
|
||||||
line-height: 1.2;
|
|
||||||
font-weight: 800;
|
|
||||||
letter-spacing: -0.02em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.title-wrap p {
|
|
||||||
margin: 8px 0 0;
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.98rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.badge {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
background: var(--accent-soft);
|
|
||||||
color: #8df0ad;
|
|
||||||
border: 1px solid rgba(34, 197, 94, 0.25);
|
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 999px;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
font-weight: 700;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-grid {
|
|
||||||
margin-top: 18px;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
|
||||||
gap: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-card {
|
|
||||||
background: var(--panel-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-label {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
text-transform: uppercase;
|
|
||||||
letter-spacing: 0.08em;
|
|
||||||
font-weight: 700;
|
|
||||||
}
|
|
||||||
|
|
||||||
.meta-value {
|
|
||||||
font-size: 0.98rem;
|
|
||||||
font-weight: 600;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-wrap {
|
|
||||||
background: var(--panel);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 16px;
|
|
||||||
overflow: hidden;
|
|
||||||
}
|
|
||||||
|
|
||||||
.messages-header {
|
|
||||||
padding: 18px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
background: var(--panel-2);
|
|
||||||
font-weight: 800;
|
|
||||||
font-size: 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
display: flex;
|
|
||||||
gap: 14px;
|
|
||||||
padding: 18px 20px;
|
|
||||||
border-bottom: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message:last-child {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.avatar {
|
|
||||||
width: 42px;
|
|
||||||
height: 42px;
|
|
||||||
min-width: 42px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
background: var(--panel-3);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-body {
|
|
||||||
min-width: 0;
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message-header {
|
|
||||||
display: flex;
|
|
||||||
align-items: baseline;
|
|
||||||
gap: 10px;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.username {
|
|
||||||
font-weight: 800;
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.98rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.userid {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.8rem;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
color: var(--muted);
|
|
||||||
font-size: 0.82rem;
|
|
||||||
margin-left: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content {
|
|
||||||
color: var(--text);
|
|
||||||
font-size: 0.96rem;
|
|
||||||
line-height: 1.6;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.content:empty {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discord-embed {
|
|
||||||
margin-top: 12px;
|
|
||||||
background: var(--panel-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-left: 4px solid var(--accent);
|
|
||||||
border-radius: 10px;
|
|
||||||
padding: 12px 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discord-embed-title {
|
|
||||||
font-weight: 800;
|
|
||||||
margin-bottom: 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.discord-embed-description {
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed-field {
|
|
||||||
margin-top: 10px;
|
|
||||||
padding-top: 10px;
|
|
||||||
border-top: 1px solid rgba(255,255,255,0.06);
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed-field-name {
|
|
||||||
font-weight: 700;
|
|
||||||
margin-bottom: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.embed-field-value {
|
|
||||||
color: var(--text);
|
|
||||||
line-height: 1.5;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment {
|
|
||||||
margin-top: 12px;
|
|
||||||
background: var(--panel-2);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-file {
|
|
||||||
display: inline-block;
|
|
||||||
color: #8fd9a8;
|
|
||||||
text-decoration: none;
|
|
||||||
font-weight: 700;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-file:hover {
|
|
||||||
text-decoration: underline;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-preview {
|
|
||||||
margin-top: 10px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-preview img,
|
|
||||||
.attachment-preview video {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 560px;
|
|
||||||
border-radius: 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
display: block;
|
|
||||||
background: #0a0f15;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attachment-preview audio {
|
|
||||||
width: 100%;
|
|
||||||
max-width: 560px;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
code {
|
|
||||||
background: var(--code);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 2px 6px;
|
|
||||||
border-radius: 6px;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
|
||||||
font-size: 0.9em;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre {
|
|
||||||
background: var(--code);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
padding: 12px;
|
|
||||||
border-radius: 10px;
|
|
||||||
overflow-x: auto;
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
|
|
||||||
pre code {
|
|
||||||
background: transparent;
|
|
||||||
border: none;
|
|
||||||
padding: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
a {
|
|
||||||
color: #8fd9ff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty-state {
|
|
||||||
padding: 24px 20px;
|
|
||||||
color: var(--muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 700px) {
|
|
||||||
body {
|
|
||||||
padding: 18px 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.header,
|
|
||||||
.messages-wrap {
|
|
||||||
border-radius: 12px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.message {
|
|
||||||
padding: 14px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.timestamp {
|
|
||||||
width: 100%;
|
|
||||||
margin-left: 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div class="page">
|
|
||||||
<section class="header">
|
|
||||||
<div class="header-top">
|
|
||||||
<div class="title-wrap">
|
|
||||||
<h1>Ticket Transcript</h1>
|
|
||||||
<p>Exported conversation log for support ticket <strong>${escapeHtml(ticket.name)}</strong></p>
|
|
||||||
</div>
|
|
||||||
<div class="badge">Closed Transcript</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="meta-grid">
|
|
||||||
<div class="meta-card">
|
|
||||||
<div class="meta-label">Channel</div>
|
|
||||||
<div class="meta-value">${escapeHtml(ticket.name)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="meta-card">
|
|
||||||
<div class="meta-label">Ticket ID</div>
|
|
||||||
<div class="meta-value">${escapeHtml(ticket.id)}</div>
|
|
||||||
</div>
|
|
||||||
<div class="meta-card">
|
|
||||||
<div class="meta-label">Message Count</div>
|
|
||||||
<div class="meta-value">${ticket.messages.length}</div>
|
|
||||||
</div>
|
|
||||||
<div class="meta-card">
|
|
||||||
<div class="meta-label">Close Reason</div>
|
|
||||||
<div class="meta-value">${escapeHtml(reason || "No Reason Provided.")}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section class="messages-wrap">
|
|
||||||
<div class="messages-header">Messages</div>
|
|
||||||
${
|
|
||||||
ticket.messages.length
|
|
||||||
? ticket.messages
|
|
||||||
.map(
|
|
||||||
(msg) => `
|
|
||||||
<article class="message">
|
|
||||||
<img src="${escapeHtml(msg.avatar || "")}" alt="Avatar" class="avatar">
|
|
||||||
<div class="message-body">
|
|
||||||
<div class="message-header">
|
|
||||||
<span class="username">${escapeHtml(msg.username || "Unknown User")}</span>
|
|
||||||
<span class="userid">${escapeHtml(msg.userId || "Unknown ID")}</span>
|
|
||||||
<span class="timestamp">${formatDate(msg.timestamp)}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="content">${parseMarkdown(msg.content || "")}</div>
|
|
||||||
|
|
||||||
${renderEmbeds(msg.embeds || [])}
|
|
||||||
${(msg.attachments || []).map(renderAttachment).join("")}
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
`,
|
|
||||||
)
|
|
||||||
.join("")
|
|
||||||
: `<div class="empty-state">No messages were found in this ticket.</div>`
|
|
||||||
}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</body>
|
|
||||||
</html>`;
|
|
||||||
}
|
|
||||||
|
|
||||||
async function createTranscript(interaction, ticket, reason) {
|
|
||||||
const { getChannel } = interaction.client;
|
const { getChannel } = interaction.client;
|
||||||
|
|
||||||
const transcriptChannel = await getChannel(
|
const transcriptChannel = await getChannel(
|
||||||
@@ -574,12 +195,27 @@ async function createTranscript(interaction, ticket, reason) {
|
|||||||
db.updateTicketStatus(interaction.channel.id, "closed");
|
db.updateTicketStatus(interaction.channel.id, "closed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const ticketUserId = ticket.user_id || ticket.user;
|
let ticketUserId = ticketRecord?.user_id || ticketRecord?.user || ticketRecord?.author_id;
|
||||||
|
|
||||||
|
if (!ticketUserId && interaction.channel.permissionOverwrites) {
|
||||||
|
const memberOverwrite = interaction.channel.permissionOverwrites.cache.find(
|
||||||
|
(pow) => pow.type === 1 && pow.id !== interaction.client.user.id
|
||||||
|
);
|
||||||
|
if (memberOverwrite) {
|
||||||
|
ticketUserId = memberOverwrite.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!ticketUserId && allMessages.length > 0) {
|
||||||
|
const firstNonBotMsg = allMessages.find((m) => !m.author.bot);
|
||||||
|
if (firstNonBotMsg) {
|
||||||
|
ticketUserId = firstNonBotMsg.author.id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const tUser =
|
const tUser =
|
||||||
interaction.client.users.cache.get(ticketUserId) ||
|
(ticketUserId ? interaction.client.users.cache.get(ticketUserId) : null) ||
|
||||||
(ticketUserId
|
(ticketUserId ? await interaction.client.users.fetch(ticketUserId).catch(() => null) : null);
|
||||||
? await interaction.client.users.fetch(ticketUserId).catch(() => null)
|
|
||||||
: null);
|
|
||||||
|
|
||||||
const transcriptEmbed = new Discord.EmbedBuilder()
|
const transcriptEmbed = new Discord.EmbedBuilder()
|
||||||
.setTitle(msgconfig.TicketLog.Title)
|
.setTitle(msgconfig.TicketLog.Title)
|
||||||
@@ -590,16 +226,37 @@ async function createTranscript(interaction, ticket, reason) {
|
|||||||
})
|
})
|
||||||
.setDescription(
|
.setDescription(
|
||||||
`> **Ticket:** ${interaction.channel.name} (\`${interaction.channel.id}\`)\n` +
|
`> **Ticket:** ${interaction.channel.name} (\`${interaction.channel.id}\`)\n` +
|
||||||
`> **User:** ${tUser?.tag || "Unknown User"} (\`${tUser?.id || ticketUserId || "Unknown"}\`)\n` +
|
`> **User:** ${tUser ? `<@${tUser.id}> (\`${tUser.tag}\`)` : "Unknown User"}\n` +
|
||||||
`> **Closed by:** <@${interaction.user.id}>\n` +
|
`> **Closed by:** <@${interaction.user.id}>\n` +
|
||||||
`> **Message Count:** ${transcriptData.length}`,
|
`> **Message Count:** ${transcriptData.length}`,
|
||||||
)
|
)
|
||||||
.addFields({
|
.addFields({
|
||||||
name: "Reason",
|
name: "Reason",
|
||||||
value: `\`\`\`${reason || "No Reason Provided."}\`\`\``,
|
value: `\`\`\`${closeReason || "No Reason Provided."}\`\`\``,
|
||||||
inline: false,
|
inline: false,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const configStore = require("./ConfigStore");
|
||||||
|
const transcriptStore = require("./TranscriptStore");
|
||||||
|
const apiConfig = configStore.api?.API;
|
||||||
|
const tSettings = transcriptStore.getSettings();
|
||||||
|
|
||||||
|
if (apiConfig?.Enabled !== false) {
|
||||||
|
const port = apiConfig?.Port || 3000;
|
||||||
|
const isPublic = Boolean(tSettings?.publicAccess?.enabled);
|
||||||
|
const transcriptUrl = `http://localhost:${port}/transcripts/${interaction.channel.id}`;
|
||||||
|
|
||||||
|
transcriptEmbed.addFields({
|
||||||
|
name: isPublic ? "Public Web Transcript" : "Dashboard Transcript",
|
||||||
|
value: `[${isPublic ? "Click here to view public transcript" : "Click here to view in Dashboard"}](${transcriptUrl})`,
|
||||||
|
inline: false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[TicketManager] Could not add web transcript URL:", err.message);
|
||||||
|
}
|
||||||
|
|
||||||
const transcriptDir = path.join(process.cwd(), "./Data/Transcripts");
|
const transcriptDir = path.join(process.cwd(), "./Data/Transcripts");
|
||||||
if (!fs.existsSync(transcriptDir)) {
|
if (!fs.existsSync(transcriptDir)) {
|
||||||
fs.mkdirSync(transcriptDir, { recursive: true });
|
fs.mkdirSync(transcriptDir, { recursive: true });
|
||||||
@@ -614,7 +271,7 @@ async function createTranscript(interaction, ticket, reason) {
|
|||||||
name: interaction.channel.name,
|
name: interaction.channel.name,
|
||||||
messages: transcriptData,
|
messages: transcriptData,
|
||||||
},
|
},
|
||||||
reason,
|
closeReason,
|
||||||
);
|
);
|
||||||
|
|
||||||
fs.writeFileSync(htmlPath, html, "utf8");
|
fs.writeFileSync(htmlPath, html, "utf8");
|
||||||
@@ -633,4 +290,4 @@ async function createTranscript(interaction, ticket, reason) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createTranscript,
|
createTranscript,
|
||||||
};
|
};
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
const TRANSCRIPT_DIR = path.join(__dirname, '../Data/Transcripts');
|
||||||
|
const SETTINGS_FILE = path.join(__dirname, '../Data/transcript-settings.json');
|
||||||
|
|
||||||
|
const DEFAULT_SETTINGS = {
|
||||||
|
autoDelete: {
|
||||||
|
enabled: false,
|
||||||
|
afterDays: 30,
|
||||||
|
},
|
||||||
|
publicAccess: {
|
||||||
|
enabled: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
let purgeTimer = null;
|
||||||
|
|
||||||
|
function ensureTranscriptDir() {
|
||||||
|
if (!fs.existsSync(TRANSCRIPT_DIR)) {
|
||||||
|
fs.mkdirSync(TRANSCRIPT_DIR, { recursive: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSettings() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(SETTINGS_FILE)) {
|
||||||
|
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(fs.readFileSync(SETTINGS_FILE, 'utf8'));
|
||||||
|
const days = Number(raw?.autoDelete?.afterDays);
|
||||||
|
return {
|
||||||
|
autoDelete: {
|
||||||
|
enabled: Boolean(raw?.autoDelete?.enabled),
|
||||||
|
afterDays:
|
||||||
|
Number.isFinite(days) && days >= 1
|
||||||
|
? Math.min(Math.floor(days), 3650)
|
||||||
|
: DEFAULT_SETTINGS.autoDelete.afterDays,
|
||||||
|
},
|
||||||
|
publicAccess: {
|
||||||
|
enabled: Boolean(raw?.publicAccess?.enabled),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return JSON.parse(JSON.stringify(DEFAULT_SETTINGS));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveSettings(patch) {
|
||||||
|
const current = getSettings();
|
||||||
|
const next = {
|
||||||
|
autoDelete: {
|
||||||
|
enabled:
|
||||||
|
patch?.autoDelete?.enabled !== undefined
|
||||||
|
? Boolean(patch.autoDelete.enabled)
|
||||||
|
: current.autoDelete.enabled,
|
||||||
|
afterDays:
|
||||||
|
patch?.autoDelete?.afterDays !== undefined
|
||||||
|
? Math.min(
|
||||||
|
Math.max(Math.floor(Number(patch.autoDelete.afterDays) || 1), 1),
|
||||||
|
3650,
|
||||||
|
)
|
||||||
|
: current.autoDelete.afterDays,
|
||||||
|
},
|
||||||
|
publicAccess: {
|
||||||
|
enabled:
|
||||||
|
patch?.publicAccess?.enabled !== undefined
|
||||||
|
? Boolean(patch.publicAccess.enabled)
|
||||||
|
: current.publicAccess.enabled,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const dir = path.dirname(SETTINGS_FILE);
|
||||||
|
if (!fs.existsSync(dir)) {
|
||||||
|
fs.mkdirSync(dir, { recursive: true });
|
||||||
|
}
|
||||||
|
fs.writeFileSync(SETTINGS_FILE, JSON.stringify(next, null, 2), 'utf8');
|
||||||
|
return next;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeId(rawId) {
|
||||||
|
return String(rawId).replace(/[^0-9]/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function readTranscriptFile(rawId) {
|
||||||
|
const id = normalizeId(rawId);
|
||||||
|
if (!id) return null;
|
||||||
|
ensureTranscriptDir();
|
||||||
|
const filePath = path.join(TRANSCRIPT_DIR, `${id}-transcript.html`);
|
||||||
|
if (!fs.existsSync(filePath)) return null;
|
||||||
|
const content = fs.readFileSync(filePath, 'utf8');
|
||||||
|
const titleMatch = content.match(/<title>\s*Transcript\s*-\s*([^<]+)\s*<\/title>/i);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
filePath,
|
||||||
|
filename: `${id}-transcript.html`,
|
||||||
|
content,
|
||||||
|
ticketName: titleMatch ? titleMatch[1].trim() : null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function listTranscripts() {
|
||||||
|
ensureTranscriptDir();
|
||||||
|
if (!fs.existsSync(TRANSCRIPT_DIR)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return fs
|
||||||
|
.readdirSync(TRANSCRIPT_DIR)
|
||||||
|
.filter((f) => f.endsWith('-transcript.html'))
|
||||||
|
.map((f) => {
|
||||||
|
const id = f.replace('-transcript.html', '');
|
||||||
|
const stats = fs.statSync(path.join(TRANSCRIPT_DIR, f));
|
||||||
|
const parsed = readTranscriptFile(id);
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
filename: f,
|
||||||
|
createdAt: stats.mtime.toISOString(),
|
||||||
|
size: stats.size,
|
||||||
|
ticketName: parsed?.ticketName ?? null,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.sort((a, b) => new Date(b.createdAt) - new Date(a.createdAt));
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteTranscript(rawId) {
|
||||||
|
const id = normalizeId(rawId);
|
||||||
|
if (!id) return false;
|
||||||
|
const filePath = path.join(TRANSCRIPT_DIR, `${id}-transcript.html`);
|
||||||
|
if (!fs.existsSync(filePath)) return false;
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function purgeExpiredTranscripts() {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (!settings.autoDelete.enabled) {
|
||||||
|
return { deleted: 0, skipped: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureTranscriptDir();
|
||||||
|
const maxAgeMs = settings.autoDelete.afterDays * 24 * 60 * 60 * 1000;
|
||||||
|
const cutoff = Date.now() - maxAgeMs;
|
||||||
|
let deleted = 0;
|
||||||
|
|
||||||
|
for (const f of fs.readdirSync(TRANSCRIPT_DIR)) {
|
||||||
|
if (!f.endsWith('-transcript.html')) continue;
|
||||||
|
const filePath = path.join(TRANSCRIPT_DIR, f);
|
||||||
|
const stats = fs.statSync(filePath);
|
||||||
|
if (stats.mtime.getTime() < cutoff) {
|
||||||
|
fs.unlinkSync(filePath);
|
||||||
|
deleted += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { deleted, skipped: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
function notFoundHtml(message = 'Transcript not found') {
|
||||||
|
return `<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Transcript</title>
|
||||||
|
<style>body{font-family:system-ui,sans-serif;padding:2rem;background:#0b0f14;color:#e6edf3;margin:0}</style></head>
|
||||||
|
<body><h1>${message}</h1></body></html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function servePublicTranscript(req, res) {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (!settings.publicAccess.enabled) {
|
||||||
|
return res.status(404).type('html').send(notFoundHtml('Transcript not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const transcript = readTranscriptFile(req.params.id);
|
||||||
|
if (!transcript) {
|
||||||
|
return res.status(404).type('html').send(notFoundHtml('Transcript not found'));
|
||||||
|
}
|
||||||
|
|
||||||
|
res.setHeader('Content-Type', 'text/html; charset=utf-8');
|
||||||
|
res.setHeader('Cache-Control', 'public, max-age=120');
|
||||||
|
return res.send(transcript.content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function startAutoDeleteScheduler(intervalMs = 60 * 60 * 1000) {
|
||||||
|
if (purgeTimer) return;
|
||||||
|
|
||||||
|
const run = () => {
|
||||||
|
try {
|
||||||
|
const result = purgeExpiredTranscripts();
|
||||||
|
if (result.deleted > 0) {
|
||||||
|
console.log(
|
||||||
|
`[Transcripts] Auto-deleted ${result.deleted} transcript(s) older than ${getSettings().autoDelete.afterDays} day(s)`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Transcripts] Auto-delete failed:', err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
run();
|
||||||
|
purgeTimer = setInterval(run, intervalMs);
|
||||||
|
if (typeof purgeTimer.unref === 'function') {
|
||||||
|
purgeTimer.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPublicUrl(req, id) {
|
||||||
|
const settings = getSettings();
|
||||||
|
if (!settings.publicAccess.enabled) return null;
|
||||||
|
const host = req.get('host');
|
||||||
|
const proto = req.get('x-forwarded-proto') || req.protocol || 'http';
|
||||||
|
return `${proto}://${host}/transcripts/${id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TRANSCRIPT_DIR,
|
||||||
|
DEFAULT_SETTINGS,
|
||||||
|
getSettings,
|
||||||
|
saveSettings,
|
||||||
|
readTranscriptFile,
|
||||||
|
listTranscripts,
|
||||||
|
deleteTranscript,
|
||||||
|
purgeExpiredTranscripts,
|
||||||
|
servePublicTranscript,
|
||||||
|
startAutoDeleteScheduler,
|
||||||
|
getPublicUrl,
|
||||||
|
};
|
||||||
@@ -0,0 +1,721 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const yaml = require("js-yaml");
|
||||||
|
const {
|
||||||
|
normalizeFontFamily,
|
||||||
|
resolveTypography,
|
||||||
|
} = require("./googleFonts.js");
|
||||||
|
|
||||||
|
const TEMPLATE_PATH = path.join(__dirname, "../Configs/transcript-template.yml");
|
||||||
|
|
||||||
|
let cachedTemplate = null;
|
||||||
|
|
||||||
|
function escapeHtml(str = "") {
|
||||||
|
return String(str)
|
||||||
|
.replace(/&/g, "&")
|
||||||
|
.replace(/</g, "<")
|
||||||
|
.replace(/>/g, ">")
|
||||||
|
.replace(/"/g, """);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMarkdown(content = "") {
|
||||||
|
let safe = escapeHtml(content);
|
||||||
|
|
||||||
|
safe = safe.replace(/```([\s\S]*?)```/g, "<pre><code>$1</code></pre>");
|
||||||
|
safe = safe.replace(/`(.*?)`/g, "<code>$1</code>");
|
||||||
|
safe = safe.replace(/\|\|(.*?)\|\|/g, '<span class="spoiler">$1</span>');
|
||||||
|
safe = safe.replace(/\*\*(.*?)\*\*/g, "<strong>$1</strong>");
|
||||||
|
safe = safe.replace(/\*(.*?)\*/g, "<em>$1</em>");
|
||||||
|
safe = safe.replace(/_(.*?)_/g, "<em>$1</em>");
|
||||||
|
safe = safe.replace(/~~(.*?)~~/g, "<del>$1</del>");
|
||||||
|
safe = safe.replace(/__(.*?)__/g, "<u>$1</u>");
|
||||||
|
safe = safe.replace(/<@!?(\d+)>/g, '<span class="mention">@User</span>');
|
||||||
|
safe = safe.replace(/<@&(\d+)>/g, '<span class="mention">@Role</span>');
|
||||||
|
safe = safe.replace(
|
||||||
|
/\[(.*?)\]\((.*?)\)/g,
|
||||||
|
'<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
|
||||||
|
);
|
||||||
|
|
||||||
|
return safe.replace(/\n/g, "<br>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(iso) {
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString("en-GB", {
|
||||||
|
dateStyle: "medium",
|
||||||
|
timeStyle: "short",
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return iso;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getFileTypeInfo(att) {
|
||||||
|
const fileName = String(att.name || "attachment").toLowerCase();
|
||||||
|
|
||||||
|
if (/\.(png|jpg|jpeg|gif|webp|bmp|svg)$/i.test(fileName)) {
|
||||||
|
return "image";
|
||||||
|
}
|
||||||
|
if (/\.(mp4|webm|mov|m4v)$/i.test(fileName)) {
|
||||||
|
return "video";
|
||||||
|
}
|
||||||
|
if (/\.(mp3|wav|ogg|m4a|flac)$/i.test(fileName)) {
|
||||||
|
return "audio";
|
||||||
|
}
|
||||||
|
return "file";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAttachment(att) {
|
||||||
|
const url = escapeHtml(att.url || "");
|
||||||
|
const name = escapeHtml(att.name || "attachment");
|
||||||
|
const type = getFileTypeInfo(att);
|
||||||
|
|
||||||
|
if (type === "image") {
|
||||||
|
return `
|
||||||
|
<div class="attachment">
|
||||||
|
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
||||||
|
<div class="attachment-preview attachment-image">
|
||||||
|
<img src="${url}" alt="${name}" loading="lazy">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "video") {
|
||||||
|
return `
|
||||||
|
<div class="attachment">
|
||||||
|
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
||||||
|
<div class="attachment-preview">
|
||||||
|
<video controls preload="metadata">
|
||||||
|
<source src="${url}">
|
||||||
|
</video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (type === "audio") {
|
||||||
|
return `
|
||||||
|
<div class="attachment">
|
||||||
|
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
||||||
|
<div class="attachment-preview">
|
||||||
|
<audio controls preload="metadata">
|
||||||
|
<source src="${url}">
|
||||||
|
</audio>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="attachment">
|
||||||
|
<a href="${url}" target="_blank" rel="noopener noreferrer" class="attachment-file">📎 ${name}</a>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEmbeds(embeds = []) {
|
||||||
|
return embeds
|
||||||
|
.map((embed) => {
|
||||||
|
const color = embed.color || "#5865F2";
|
||||||
|
const fields = (embed.fields || [])
|
||||||
|
.map(
|
||||||
|
(field) => `
|
||||||
|
<div class="embed-field ${field.inline ? 'inline-field' : ''}">
|
||||||
|
<div class="embed-field-name">${escapeHtml(field.name || "Field")}</div>
|
||||||
|
<div class="embed-field-value">${parseMarkdown(field.value || "")}</div>
|
||||||
|
</div>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("");
|
||||||
|
|
||||||
|
const author = embed.author ? `
|
||||||
|
<div class="embed-author">
|
||||||
|
${embed.author.iconURL ? `<img src="${escapeHtml(embed.author.iconURL)}" class="embed-author-icon" />` : ''}
|
||||||
|
<span>${escapeHtml(embed.author.name || '')}</span>
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
const thumbnail = embed.thumbnail?.url ? `
|
||||||
|
<img src="${escapeHtml(embed.thumbnail.url)}" class="embed-thumbnail" />
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
const image = embed.image?.url ? `
|
||||||
|
<div class="embed-image"><img src="${escapeHtml(embed.image.url)}" /></div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
const footer = (embed.footer || embed.timestamp) ? `
|
||||||
|
<div class="embed-footer">
|
||||||
|
${embed.footer?.iconURL ? `<img src="${escapeHtml(embed.footer.iconURL)}" class="embed-footer-icon" />` : ''}
|
||||||
|
${embed.footer?.text ? `<span>${escapeHtml(embed.footer.text)}</span>` : ''}
|
||||||
|
</div>
|
||||||
|
` : '';
|
||||||
|
|
||||||
|
return `
|
||||||
|
<div class="discord-embed" style="border-left-color: ${escapeHtml(color)}; border-left-width: 4px; border-left-style: solid; position: relative;">
|
||||||
|
${author}
|
||||||
|
${thumbnail}
|
||||||
|
${embed.title ? `<div class="discord-embed-title">${parseMarkdown(embed.title)}</div>` : ""}
|
||||||
|
${embed.description ? `<div class="discord-embed-description">${parseMarkdown(embed.description)}</div>` : ""}
|
||||||
|
${fields ? `<div class="embed-fields-grid">${fields}</div>` : ""}
|
||||||
|
${image}
|
||||||
|
${footer}
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
function deepMerge(base, patch) {
|
||||||
|
const out = structuredClone(base);
|
||||||
|
for (const [key, val] of Object.entries(patch || {})) {
|
||||||
|
if (
|
||||||
|
val &&
|
||||||
|
typeof val === "object" &&
|
||||||
|
!Array.isArray(val) &&
|
||||||
|
out[key] &&
|
||||||
|
typeof out[key] === "object" &&
|
||||||
|
!Array.isArray(out[key])
|
||||||
|
) {
|
||||||
|
out[key] = deepMerge(out[key], val);
|
||||||
|
} else {
|
||||||
|
out[key] = val;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultTemplate() {
|
||||||
|
return {
|
||||||
|
Theme: {
|
||||||
|
Background: "#0b0f14",
|
||||||
|
Panel: "#111821",
|
||||||
|
PanelSecondary: "#182230",
|
||||||
|
PanelTertiary: "#1d2938",
|
||||||
|
Border: "#283548",
|
||||||
|
Text: "#e6edf3",
|
||||||
|
Muted: "#9fb0c3",
|
||||||
|
Accent: "#22c55e",
|
||||||
|
AccentSoft: "rgba(34, 197, 94, 0.12)",
|
||||||
|
AccentBorder: "rgba(34, 197, 94, 0.25)",
|
||||||
|
AccentText: "#8df0ad",
|
||||||
|
Link: "#8fd9ff",
|
||||||
|
AttachmentLink: "#8fd9a8",
|
||||||
|
CodeBackground: "#0f1720",
|
||||||
|
},
|
||||||
|
Typography: {
|
||||||
|
FontFamily: "Inter",
|
||||||
|
},
|
||||||
|
Header: {
|
||||||
|
Title: "Ticket Transcript",
|
||||||
|
Subtitle:
|
||||||
|
"Exported conversation log for support ticket **{{ticketName}}**",
|
||||||
|
Badge: "Closed Transcript",
|
||||||
|
ShowMetaCards: true,
|
||||||
|
},
|
||||||
|
Meta: {
|
||||||
|
ChannelLabel: "Channel",
|
||||||
|
TicketIdLabel: "Ticket ID",
|
||||||
|
MessageCountLabel: "Message Count",
|
||||||
|
CloseReasonLabel: "Close Reason",
|
||||||
|
DefaultCloseReason: "No Reason Provided.",
|
||||||
|
},
|
||||||
|
Messages: {
|
||||||
|
SectionTitle: "Messages",
|
||||||
|
EmptyState: "No messages were found in this ticket.",
|
||||||
|
},
|
||||||
|
Advanced: {
|
||||||
|
CustomCss: "",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTemplate(raw) {
|
||||||
|
const merged = deepMerge(getDefaultTemplate(), raw || {});
|
||||||
|
if (merged.Typography) {
|
||||||
|
merged.Typography.FontFamily = normalizeFontFamily(
|
||||||
|
merged.Typography.FontFamily,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTemplateFromDisk() {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(TEMPLATE_PATH)) {
|
||||||
|
const defaults = getDefaultTemplate();
|
||||||
|
fs.mkdirSync(path.dirname(TEMPLATE_PATH), { recursive: true });
|
||||||
|
fs.writeFileSync(TEMPLATE_PATH, yaml.dump(defaults), "utf8");
|
||||||
|
return defaults;
|
||||||
|
}
|
||||||
|
const parsed = yaml.load(fs.readFileSync(TEMPLATE_PATH, "utf8"));
|
||||||
|
return normalizeTemplate(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
console.error("[TranscriptTemplate] Failed to load template:", err.message);
|
||||||
|
return getDefaultTemplate();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getTemplate() {
|
||||||
|
if (!cachedTemplate) {
|
||||||
|
cachedTemplate = loadTemplateFromDisk();
|
||||||
|
}
|
||||||
|
return structuredClone(cachedTemplate);
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadTemplate() {
|
||||||
|
cachedTemplate = loadTemplateFromDisk();
|
||||||
|
return getTemplate();
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveTemplate(data) {
|
||||||
|
const merged = normalizeTemplate(data);
|
||||||
|
fs.mkdirSync(path.dirname(TEMPLATE_PATH), { recursive: true });
|
||||||
|
fs.writeFileSync(TEMPLATE_PATH, yaml.dump(merged), "utf8");
|
||||||
|
cachedTemplate = merged;
|
||||||
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPlaceholders(text, ctx) {
|
||||||
|
return String(text || "")
|
||||||
|
.replace(/\{\{ticketName\}\}/g, ctx.name || "")
|
||||||
|
.replace(/\{\{ticketId\}\}/g, ctx.id || "")
|
||||||
|
.replace(/\{\{messageCount\}\}/g, String(ctx.messageCount ?? 0))
|
||||||
|
.replace(/\{\{reason\}\}/g, ctx.reason || "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatHeaderText(text, ctx) {
|
||||||
|
const withPlaceholders = applyPlaceholders(text, ctx);
|
||||||
|
const escaped = escapeHtml(withPlaceholders);
|
||||||
|
return escaped.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildStyles(template) {
|
||||||
|
const t = template.Theme;
|
||||||
|
const { fontFamilyCss } = resolveTypography(template.Typography?.FontFamily);
|
||||||
|
const custom = String(template.Advanced?.CustomCss || "").trim();
|
||||||
|
|
||||||
|
return `
|
||||||
|
:root {
|
||||||
|
--bg: ${t.Background};
|
||||||
|
--panel: ${t.Panel};
|
||||||
|
--panel-2: ${t.PanelSecondary};
|
||||||
|
--panel-3: ${t.PanelTertiary};
|
||||||
|
--border: ${t.Border};
|
||||||
|
--text: ${t.Text};
|
||||||
|
--muted: ${t.Muted};
|
||||||
|
--accent: ${t.Accent};
|
||||||
|
--accent-soft: ${t.AccentSoft};
|
||||||
|
--accent-border: ${t.AccentBorder};
|
||||||
|
--accent-text: ${t.AccentText};
|
||||||
|
--link: ${t.Link};
|
||||||
|
--attachment-link: ${t.AttachmentLink};
|
||||||
|
--code: ${t.CodeBackground};
|
||||||
|
}
|
||||||
|
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
html, body {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
font-family: ${fontFamilyCss};
|
||||||
|
}
|
||||||
|
|
||||||
|
body { padding: 32px 20px; }
|
||||||
|
|
||||||
|
.page { max-width: 1100px; margin: 0 auto; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 24px;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-top {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-wrap h1 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.8rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title-wrap p {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: var(--accent-soft);
|
||||||
|
color: var(--accent-text);
|
||||||
|
border: 1px solid var(--accent-border);
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-grid {
|
||||||
|
margin-top: 18px;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-card {
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-label {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.meta-value {
|
||||||
|
font-size: 0.98rem;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-wrap {
|
||||||
|
background: var(--panel);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.messages-header {
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--panel-2);
|
||||||
|
font-weight: 800;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.message {
|
||||||
|
display: flex;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 18px 20px;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.avatar {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
min-width: 42px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
background: var(--panel-3);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.message-body { min-width: 0; flex: 1; }
|
||||||
|
|
||||||
|
.message-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-weight: 800;
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.98rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.userid {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.timestamp {
|
||||||
|
color: var(--muted);
|
||||||
|
font-size: 0.82rem;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
color: var(--text);
|
||||||
|
font-size: 0.96rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content:empty { display: none; }
|
||||||
|
|
||||||
|
.discord-embed {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-left: 4px solid var(--accent);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.discord-embed-title { font-weight: 800; margin-bottom: 6px; }
|
||||||
|
.discord-embed-description { color: var(--text); line-height: 1.5; }
|
||||||
|
|
||||||
|
.embed-author { display: flex; align-items: center; gap: 8px; font-weight: 700; font-size: 0.9em; margin-bottom: 6px; }
|
||||||
|
.embed-author-icon { width: 18px; height: 18px; border-radius: 50%; }
|
||||||
|
.embed-fields-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 10px; margin-top: 10px; padding-top: 10px; border-top: 1px solid rgba(255,255,255,0.06); }
|
||||||
|
.embed-thumbnail { position: absolute; top: 12px; right: 12px; width: 44px; height: 44px; border-radius: 6px; object-fit: cover; }
|
||||||
|
.embed-image img { width: 100%; max-height: 300px; object-fit: cover; border-radius: 8px; margin-top: 10px; }
|
||||||
|
.embed-footer { display: flex; align-items: center; gap: 6px; font-size: 0.8em; color: var(--muted); margin-top: 8px; padding-top: 6px; border-top: 1px solid rgba(255,255,255,0.04); }
|
||||||
|
.embed-footer-icon { width: 14px; height: 14px; border-radius: 50%; }
|
||||||
|
.mention { background: rgba(88, 101, 242, 0.2); color: #5865F2; padding: 2px 6px; border-radius: 4px; font-weight: 600; font-size: 0.9em; }
|
||||||
|
.spoiler { background: #202225; color: transparent; border-radius: 4px; padding: 0 4px; cursor: pointer; }
|
||||||
|
.spoiler:hover { color: inherit; background: rgba(255,255,255,0.1); }
|
||||||
|
|
||||||
|
.embed-field {
|
||||||
|
margin-top: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.embed-field-name { font-weight: 700; margin-bottom: 4px; font-size: 0.85em; text-transform: uppercase; color: var(--muted); }
|
||||||
|
.embed-field-value { color: var(--text); line-height: 1.5; }
|
||||||
|
|
||||||
|
.attachment {
|
||||||
|
margin-top: 12px;
|
||||||
|
background: var(--panel-2);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file {
|
||||||
|
display: inline-block;
|
||||||
|
color: var(--attachment-link);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 700;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-file:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.attachment-preview { margin-top: 10px; }
|
||||||
|
|
||||||
|
.attachment-preview img,
|
||||||
|
.attachment-preview video {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
border-radius: 10px;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
display: block;
|
||||||
|
background: #0a0f15;
|
||||||
|
}
|
||||||
|
|
||||||
|
.attachment-preview audio {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 560px;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--code);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow-x: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code { background: transparent; border: none; padding: 0; }
|
||||||
|
|
||||||
|
a { color: var(--link); }
|
||||||
|
|
||||||
|
.empty-state { padding: 24px 20px; color: var(--muted); }
|
||||||
|
|
||||||
|
@media (max-width: 700px) {
|
||||||
|
body { padding: 18px 12px; }
|
||||||
|
.header, .messages-wrap { border-radius: 12px; }
|
||||||
|
.message { padding: 14px; }
|
||||||
|
.timestamp { width: 100%; margin-left: 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
${custom}
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSampleTicket() {
|
||||||
|
return {
|
||||||
|
id: "123456789012345678",
|
||||||
|
name: "ticket-support-demo",
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
username: "SupportBot",
|
||||||
|
userId: "100000000000000001",
|
||||||
|
avatar: "https://cdn.discordapp.com/embed/avatars/0.png",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
content: "Hello! Thanks for opening a ticket. How can we help?",
|
||||||
|
embeds: [],
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
username: "Member",
|
||||||
|
userId: "100000000000000002",
|
||||||
|
avatar: "https://cdn.discordapp.com/embed/avatars/1.png",
|
||||||
|
timestamp: new Date(Date.now() - 60000).toISOString(),
|
||||||
|
content: "I need help with my purchase — order **#48291**.",
|
||||||
|
embeds: [
|
||||||
|
{
|
||||||
|
title: "Order details",
|
||||||
|
description: "Payment received · awaiting delivery",
|
||||||
|
fields: [{ name: "Status", value: "Pending" }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
attachments: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createTranscriptHTML(ticket, reason, templateInput) {
|
||||||
|
const template = templateInput
|
||||||
|
? normalizeTemplate(templateInput)
|
||||||
|
: getTemplate();
|
||||||
|
const header = template.Header;
|
||||||
|
const meta = template.Meta;
|
||||||
|
const messagesCfg = template.Messages;
|
||||||
|
const closeReason = reason || meta.DefaultCloseReason;
|
||||||
|
const ctx = {
|
||||||
|
name: ticket.name,
|
||||||
|
id: ticket.id,
|
||||||
|
messageCount: ticket.messages.length,
|
||||||
|
reason: closeReason,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { headLinks } = resolveTypography(template.Typography?.FontFamily);
|
||||||
|
|
||||||
|
const metaSection = header.ShowMetaCards
|
||||||
|
? `
|
||||||
|
<div class="meta-grid">
|
||||||
|
<div class="meta-card">
|
||||||
|
<div class="meta-label">${escapeHtml(meta.ChannelLabel)}</div>
|
||||||
|
<div class="meta-value">${escapeHtml(ticket.name)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-card">
|
||||||
|
<div class="meta-label">${escapeHtml(meta.TicketIdLabel)}</div>
|
||||||
|
<div class="meta-value">${escapeHtml(ticket.id)}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-card">
|
||||||
|
<div class="meta-label">${escapeHtml(meta.MessageCountLabel)}</div>
|
||||||
|
<div class="meta-value">${ticket.messages.length}</div>
|
||||||
|
</div>
|
||||||
|
<div class="meta-card">
|
||||||
|
<div class="meta-label">${escapeHtml(meta.CloseReasonLabel)}</div>
|
||||||
|
<div class="meta-value">${escapeHtml(closeReason)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Transcript - ${escapeHtml(ticket.name)}</title>
|
||||||
|
${headLinks}
|
||||||
|
<style>${buildStyles(template)}</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="page">
|
||||||
|
<section class="header">
|
||||||
|
<div class="header-top">
|
||||||
|
<div class="title-wrap">
|
||||||
|
<h1>${escapeHtml(header.Title)}</h1>
|
||||||
|
<p>${formatHeaderText(header.Subtitle, ctx)}</p>
|
||||||
|
</div>
|
||||||
|
<div class="badge">${escapeHtml(header.Badge)}</div>
|
||||||
|
</div>
|
||||||
|
${metaSection}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="messages-wrap">
|
||||||
|
<div class="messages-header">${escapeHtml(messagesCfg.SectionTitle)}</div>
|
||||||
|
${
|
||||||
|
ticket.messages.length
|
||||||
|
? ticket.messages
|
||||||
|
.map(
|
||||||
|
(msg) => `
|
||||||
|
<article class="message">
|
||||||
|
<img src="${escapeHtml(msg.avatar || "")}" alt="Avatar" class="avatar">
|
||||||
|
<div class="message-body">
|
||||||
|
<div class="message-header">
|
||||||
|
<span class="username">${escapeHtml(msg.username || "Unknown User")}</span>
|
||||||
|
<span class="userid">${escapeHtml(msg.userId || "Unknown ID")}</span>
|
||||||
|
<span class="timestamp">${formatDate(msg.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="content">${parseMarkdown(msg.content || "")}</div>
|
||||||
|
${renderEmbeds(msg.embeds || [])}
|
||||||
|
${(msg.attachments || []).map(renderAttachment).join("")}
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
`,
|
||||||
|
)
|
||||||
|
.join("")
|
||||||
|
: `<div class="empty-state">${escapeHtml(messagesCfg.EmptyState)}</div>`
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPreview(templateInput) {
|
||||||
|
const sample = getSampleTicket();
|
||||||
|
return createTranscriptHTML(
|
||||||
|
sample,
|
||||||
|
"Resolved — thank you for contacting support.",
|
||||||
|
templateInput,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
TEMPLATE_PATH,
|
||||||
|
getDefaultTemplate,
|
||||||
|
getTemplate,
|
||||||
|
reloadTemplate,
|
||||||
|
saveTemplate,
|
||||||
|
normalizeTemplate,
|
||||||
|
getSampleTicket,
|
||||||
|
createTranscriptHTML,
|
||||||
|
renderPreview,
|
||||||
|
};
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
|
||||||
|
const REPOSITORIES = [
|
||||||
|
{ id: "bot", name: "SupportBot", repo: "Emerald-Services/SupportBot", branch: "release" },
|
||||||
|
{ id: "dashboard", name: "SupportBot Dashboard", repo: "Emerald-Services/SupportBot-Dashboard", branch: "release" }
|
||||||
|
];
|
||||||
|
|
||||||
|
function compareVersions(a, b) {
|
||||||
|
const pa = String(a)
|
||||||
|
.replace(/^v/i, "")
|
||||||
|
.split(".")
|
||||||
|
.map((n) => parseInt(n, 10) || 0);
|
||||||
|
const pb = String(b)
|
||||||
|
.replace(/^v/i, "")
|
||||||
|
.split(".")
|
||||||
|
.map((n) => parseInt(n, 10) || 0);
|
||||||
|
for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
|
||||||
|
if ((pa[i] || 0) > (pb[i] || 0)) return 1;
|
||||||
|
if ((pa[i] || 0) < (pb[i] || 0)) return -1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchUpdateInfo() {
|
||||||
|
const axios = require("axios");
|
||||||
|
const pkgPath = path.join(__dirname, "../package.json");
|
||||||
|
const currentPkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
||||||
|
const currentVersion = currentPkg.version;
|
||||||
|
|
||||||
|
const checks = [];
|
||||||
|
|
||||||
|
for (const target of REPOSITORIES) {
|
||||||
|
let installedVersion = target.id === "bot" ? currentPkg.version : (currentPkg.dashboardVersion || "1.0.0");
|
||||||
|
let latest = installedVersion;
|
||||||
|
try {
|
||||||
|
const pkgUrl = target.id === 'dashboard'
|
||||||
|
? `https://raw.githubusercontent.com/${target.repo}/${target.branch}/dashboard/package.json`
|
||||||
|
: `https://raw.githubusercontent.com/${target.repo}/${target.branch}/package.json`;
|
||||||
|
|
||||||
|
const remoteRes = await axios.get(pkgUrl, {
|
||||||
|
timeout: 15000,
|
||||||
|
headers: { "User-Agent": "SupportBot-Updater" },
|
||||||
|
});
|
||||||
|
if (remoteRes.data && remoteRes.data.version) {
|
||||||
|
latest = remoteRes.data.version;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.warn(`[Updater] Failed to check ${target.repo} for updates.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateAvailable = compareVersions(latest, installedVersion) > 0;
|
||||||
|
|
||||||
|
checks.push({
|
||||||
|
id: target.id,
|
||||||
|
name: target.name,
|
||||||
|
current: installedVersion,
|
||||||
|
latest,
|
||||||
|
updateAvailable,
|
||||||
|
repository: `https://github.com/${target.repo}`,
|
||||||
|
branch: target.branch,
|
||||||
|
zipUrl: `https://github.com/${target.repo}/archive/refs/heads/${target.branch}.zip`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return checks;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
compareVersions,
|
||||||
|
fetchUpdateInfo,
|
||||||
|
REPOSITORIES,
|
||||||
|
};
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
function withTimeout(promise, ms, message) {
|
||||||
|
let timer;
|
||||||
|
const timeout = new Promise((_, reject) => {
|
||||||
|
timer = setTimeout(() => {
|
||||||
|
reject(new Error(message || `Timed out after ${ms}ms`));
|
||||||
|
}, ms);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Promise.race([promise, timeout]).finally(() => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { withTimeout };
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
/** Curated Google Fonts for transcript HTML (keep in sync with dashboard/src/lib/google-fonts.ts). */
|
||||||
|
|
||||||
|
const SYSTEM_FALLBACK =
|
||||||
|
'ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif';
|
||||||
|
|
||||||
|
const GOOGLE_FONTS = [
|
||||||
|
{ id: "Inter", label: "Inter" },
|
||||||
|
{ id: "Roboto", label: "Roboto" },
|
||||||
|
{ id: "Open Sans", label: "Open Sans" },
|
||||||
|
{ id: "Lato", label: "Lato" },
|
||||||
|
{ id: "Montserrat", label: "Montserrat" },
|
||||||
|
{ id: "Poppins", label: "Poppins" },
|
||||||
|
{ id: "Nunito", label: "Nunito" },
|
||||||
|
{ id: "Raleway", label: "Raleway" },
|
||||||
|
{ id: "Work Sans", label: "Work Sans" },
|
||||||
|
{ id: "Source Sans 3", label: "Source Sans 3" },
|
||||||
|
{ id: "DM Sans", label: "DM Sans" },
|
||||||
|
{ id: "Manrope", label: "Manrope" },
|
||||||
|
{ id: "Outfit", label: "Outfit" },
|
||||||
|
{ id: "Rubik", label: "Rubik" },
|
||||||
|
{ id: "Ubuntu", label: "Ubuntu" },
|
||||||
|
{ id: "Fira Sans", label: "Fira Sans" },
|
||||||
|
{ id: "PT Sans", label: "PT Sans" },
|
||||||
|
{ id: "Noto Sans", label: "Noto Sans" },
|
||||||
|
{ id: "Oswald", label: "Oswald" },
|
||||||
|
{ id: "Merriweather", label: "Merriweather" },
|
||||||
|
{ id: "Playfair Display", label: "Playfair Display" },
|
||||||
|
{ id: "Libre Baskerville", label: "Libre Baskerville" },
|
||||||
|
{ id: "JetBrains Mono", label: "JetBrains Mono" },
|
||||||
|
{ id: "Fira Code", label: "Fira Code" },
|
||||||
|
];
|
||||||
|
|
||||||
|
const GOOGLE_FONT_IDS = new Set(GOOGLE_FONTS.map((f) => f.id));
|
||||||
|
|
||||||
|
function normalizeFontFamily(fontFamily) {
|
||||||
|
if (!fontFamily || typeof fontFamily !== "string") return "Inter";
|
||||||
|
const trimmed = fontFamily.trim();
|
||||||
|
if (trimmed === "system") return "system";
|
||||||
|
if (!trimmed.includes(",")) {
|
||||||
|
return GOOGLE_FONT_IDS.has(trimmed) ? trimmed : "Inter";
|
||||||
|
}
|
||||||
|
const match = trimmed.match(/^["']?([^"',]+)["']?/);
|
||||||
|
const first = match?.[1]?.trim();
|
||||||
|
if (first && GOOGLE_FONT_IDS.has(first)) return first;
|
||||||
|
return "Inter";
|
||||||
|
}
|
||||||
|
|
||||||
|
function googleFontsCssUrl(familyIds) {
|
||||||
|
const families = familyIds
|
||||||
|
.map((id) => {
|
||||||
|
const encoded = encodeURIComponent(id).replace(/%20/g, "+");
|
||||||
|
return `family=${encoded}:wght@400;600;700;800`;
|
||||||
|
})
|
||||||
|
.join("&");
|
||||||
|
return `https://fonts.googleapis.com/css2?${families}&display=swap`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveTypography(fontFamily) {
|
||||||
|
const id = normalizeFontFamily(fontFamily);
|
||||||
|
|
||||||
|
if (id === "system") {
|
||||||
|
return {
|
||||||
|
fontId: "system",
|
||||||
|
fontFamilyCss: SYSTEM_FALLBACK,
|
||||||
|
headLinks: "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = googleFontsCssUrl([id]);
|
||||||
|
return {
|
||||||
|
fontId: id,
|
||||||
|
fontFamilyCss: `"${id}", ${SYSTEM_FALLBACK}`,
|
||||||
|
headLinks: `<link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><link href="${url}" rel="stylesheet">`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GOOGLE_FONTS,
|
||||||
|
GOOGLE_FONT_IDS,
|
||||||
|
SYSTEM_FALLBACK,
|
||||||
|
normalizeFontFamily,
|
||||||
|
googleFontsCssUrl,
|
||||||
|
resolveTypography,
|
||||||
|
};
|
||||||
@@ -13,24 +13,37 @@
|
|||||||
const fs = require("fs");
|
const fs = require("fs");
|
||||||
const path = require("path");
|
const path = require("path");
|
||||||
|
|
||||||
const yaml = require("js-yaml");
|
const configStore = require("./Structures/ConfigStore.js");
|
||||||
const supportbot = yaml.load(
|
|
||||||
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
|
||||||
);
|
|
||||||
|
|
||||||
const Client = require("./Structures/Client.js");
|
const Client = require("./Structures/Client.js");
|
||||||
const client = new Client({
|
const client = new Client({
|
||||||
intents: ['Guilds', 'GuildMembers', 'GuildMessages', 'MessageContent']
|
intents: ["Guilds", "GuildMembers", "GuildMessages", "MessageContent"],
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const { isSetupComplete } = require("./Structures/DashboardSetup.js");
|
||||||
const APIServer = require("./API/server.js");
|
const APIServer = require("./API/server.js");
|
||||||
|
|
||||||
client.start(supportbot.General.Token);
|
const api = new APIServer(client);
|
||||||
|
client.apiServer = api;
|
||||||
|
|
||||||
client.once('ready', () => {
|
const port = api.config?.Port || 25575;
|
||||||
const api = new APIServer(client);
|
if (api.config?.Enabled) {
|
||||||
api.start();
|
api.start(port);
|
||||||
});
|
if (!isSetupComplete()) {
|
||||||
|
console.log(`\n\x1b[33m\x1b[1m[Setup Required] Go to http://localhost:${port}/setup to set up your bot and dashboard.\x1b[0m\n`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
console.warn("[Dashboard] API disabled in Configs/api.yml");
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = configStore.supportbot?.General?.Token;
|
||||||
|
const isPlaceholderToken = !token || token.includes("BOT_TOKEN") || token.includes("CHANGE_ME") || token.length < 30;
|
||||||
|
|
||||||
|
if (isPlaceholderToken) {
|
||||||
|
console.warn(`\x1b[33m[SupportBot] Bot token is not configured yet. Complete setup at http://localhost:${port}/setup\x1b[0m`);
|
||||||
|
} else {
|
||||||
|
client.start(token);
|
||||||
|
}
|
||||||
|
|
||||||
// SupportBot - New Logging System
|
// SupportBot - New Logging System
|
||||||
|
|
||||||
@@ -49,7 +62,16 @@ logTypes.forEach((type) => {
|
|||||||
function logToFile(type, data) {
|
function logToFile(type, data) {
|
||||||
const date = new Date().toISOString().split("T")[0];
|
const date = new Date().toISOString().split("T")[0];
|
||||||
const file = path.join(`./Logs/${type}`, `${type}-${date}.log`);
|
const file = path.join(`./Logs/${type}`, `${type}-${date}.log`);
|
||||||
fs.appendFileSync(file, `[${new Date().toISOString()}] ${data}\n`);
|
const timestamp = new Date().toISOString();
|
||||||
|
fs.appendFileSync(file, `[${timestamp}] ${data}\n`);
|
||||||
|
|
||||||
|
if (global.apiServer) {
|
||||||
|
global.apiServer.broadcast("log", {
|
||||||
|
type,
|
||||||
|
timestamp,
|
||||||
|
message: String(data)
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const origLog = console.log;
|
const origLog = console.log;
|
||||||
@@ -77,14 +99,3 @@ process.on("unhandledRejection", (reason) => {
|
|||||||
process.on("uncaughtException", (err) => {
|
process.on("uncaughtException", (err) => {
|
||||||
console.error("Uncaught Exception:", err);
|
console.error("Uncaught Exception:", err);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
Generated
+2625
File diff suppressed because it is too large.
Load diff
+8
-4
@@ -1,11 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "supportbot",
|
"name": "supportbot",
|
||||||
"version": "26.0.0-prerelease-1",
|
"version": "26.2.0",
|
||||||
"description": "SupportBot is an open-sourced support system for discord. It's completely free to use with high amounts of customization to match your own brand.",
|
"description": "SupportBot is an open-sourced support system for discord. It's completely free to use with high amounts of customization to match your own brand.",
|
||||||
"main": "index.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"setup": "npm i && node index.js",
|
"setup": "npm i && node index.js",
|
||||||
"start": "node index.js"
|
"start": "node index.js",
|
||||||
|
"git:setup": "node scripts/setup-git-security.js"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
@@ -16,11 +17,13 @@
|
|||||||
"bugs": {
|
"bugs": {
|
||||||
"url": "https://github.com/Emerald-Services/SupportBot/issues"
|
"url": "https://github.com/Emerald-Services/SupportBot/issues"
|
||||||
},
|
},
|
||||||
"homepage": "https://community.emeraldsrv.dev",
|
"homepage": "https://emeraldsrv.dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@vitalets/google-translate-api": "^9.2.1",
|
"@vitalets/google-translate-api": "^9.2.1",
|
||||||
|
"adm-zip": "^0.6.0",
|
||||||
"axios": "^1.7.2",
|
"axios": "^1.7.2",
|
||||||
"better-sqlite3": "^12.2.0",
|
"better-sqlite3": "^12.2.0",
|
||||||
|
"cookie-parser": "^1.4.7",
|
||||||
"cors": "^2.8.6",
|
"cors": "^2.8.6",
|
||||||
"discord.js": "^14.13.0",
|
"discord.js": "^14.13.0",
|
||||||
"express": "^5.2.1",
|
"express": "^5.2.1",
|
||||||
@@ -31,8 +34,9 @@
|
|||||||
"js-yaml": "^4.1.0",
|
"js-yaml": "^4.1.0",
|
||||||
"moment": "^2.29.3",
|
"moment": "^2.29.3",
|
||||||
"ms": "^2.1.3",
|
"ms": "^2.1.3",
|
||||||
|
"mysql2": "^3.23.3",
|
||||||
"openai": "^4.50.0",
|
"openai": "^4.50.0",
|
||||||
"systeminformation": "^5.31.5",
|
"systeminformation": "^5.31.5",
|
||||||
"yaml": "^2.8.3"
|
"yaml": "^2.8.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
var w3=Object.defineProperty;var xy=t=>{throw TypeError(t)};var j3=(t,n,a)=>n in t?w3(t,n,{enumerable:!0,configurable:!0,writable:!0,value:a}):t[n]=a;var by=(t,n,a)=>j3(t,typeof n!="symbol"?n+"":n,a),vy=(t,n,a)=>n.has(t)||xy("Cannot "+a);var pn=(t,n,a)=>(vy(t,n,"read from private field"),a?a.call(t):n.get(t)),yy=(t,n,a)=>n.has(t)?xy("Cannot add the same private member more than once"):n instanceof WeakSet?n.add(t):n.set(t,a),dp=(t,n,a,o)=>(vy(t,n,"write to private field"),o?o.call(t,a):n.set(t,a),a);function C3(t,n){for(var a=0;a<n.length;a++){const o=n[a];if(typeof o!="string"&&!Array.isArray(o)){for(const c in o)if(c!=="default"&&!(c in t)){const l=Object.getOwnPropertyDescriptor(o,c);l&&Object.defineProperty(t,c,l.get?l:{enumerable:!0,get:()=>o[c]})}}}return Object.freeze(Object.defineProperty(t,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const c of document.querySelectorAll('link[rel="modulepreload"]'))o(c);new MutationObserver(c=>{for(const l of c)if(l.type==="childList")for(const d of l.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&o(d)}).observe(document,{childList:!0,subtree:!0});function a(c){const l={};return c.integrity&&(l.integrity=c.integrity),c.referrerPolicy&&(l.referrerPolicy=c.referrerPolicy),c.crossOrigin==="use-credentials"?l.credentials="include":c.crossOrigin==="anonymous"?l.credentials="omit":l.credentials="same-origin",l}function o(c){if(c.ep)return;c.ep=!0;const l=a(c);fetch(c.href,l)}})();function s2(t){return t&&t.__esModule&&Object.prototype.hasOwnProperty.call(t,"default")?t.default:t}var fp={exports:{}},Sl={};/**
|
||||||
|
* @license React
|
||||||
|
* react-jsx-runtime.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var wy;function S3(){if(wy)return Sl;wy=1;var t=Symbol.for("react.transitional.element"),n=Symbol.for("react.fragment");function a(o,c,l){var d=null;if(l!==void 0&&(d=""+l),c.key!==void 0&&(d=""+c.key),"key"in c){l={};for(var m in c)m!=="key"&&(l[m]=c[m])}else l=c;return c=l.ref,{$$typeof:t,type:o,key:d,ref:c!==void 0?c:null,props:l}}return Sl.Fragment=n,Sl.jsx=a,Sl.jsxs=a,Sl}var jy;function N3(){return jy||(jy=1,fp.exports=S3()),fp.exports}var s=N3(),mp={exports:{}},ze={};/**
|
||||||
|
* @license React
|
||||||
|
* react.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var Cy;function E3(){if(Cy)return ze;Cy=1;var t=Symbol.for("react.transitional.element"),n=Symbol.for("react.portal"),a=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),c=Symbol.for("react.profiler"),l=Symbol.for("react.consumer"),d=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),g=Symbol.for("react.memo"),b=Symbol.for("react.lazy"),v=Symbol.for("react.activity"),w=Symbol.iterator;function y(O){return O===null||typeof O!="object"?null:(O=w&&O[w]||O["@@iterator"],typeof O=="function"?O:null)}var C={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},S=Object.assign,N={};function E(O,D,B){this.props=O,this.context=D,this.refs=N,this.updater=B||C}E.prototype.isReactComponent={},E.prototype.setState=function(O,D){if(typeof O!="object"&&typeof O!="function"&&O!=null)throw Error("takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,O,D,"setState")},E.prototype.forceUpdate=function(O){this.updater.enqueueForceUpdate(this,O,"forceUpdate")};function T(){}T.prototype=E.prototype;function M(O,D,B){this.props=O,this.context=D,this.refs=N,this.updater=B||C}var k=M.prototype=new T;k.constructor=M,S(k,E.prototype),k.isPureReactComponent=!0;var _=Array.isArray;function R(){}var L={H:null,A:null,T:null,S:null},ne=Object.prototype.hasOwnProperty;function X(O,D,B){var V=B.ref;return{$$typeof:t,type:O,key:D,ref:V!==void 0?V:null,props:B}}function I(O,D){return X(O.type,D,O.props)}function H(O){return typeof O=="object"&&O!==null&&O.$$typeof===t}function q(O){var D={"=":"=0",":":"=2"};return"$"+O.replace(/[=:]/g,function(B){return D[B]})}var ce=/\/+/g;function de(O,D){return typeof O=="object"&&O!==null&&O.key!=null?q(""+O.key):D.toString(36)}function se(O){switch(O.status){case"fulfilled":return O.value;case"rejected":throw O.reason;default:switch(typeof O.status=="string"?O.then(R,R):(O.status="pending",O.then(function(D){O.status==="pending"&&(O.status="fulfilled",O.value=D)},function(D){O.status==="pending"&&(O.status="rejected",O.reason=D)})),O.status){case"fulfilled":return O.value;case"rejected":throw O.reason}}throw O}function P(O,D,B,V,Z){var be=typeof O;(be==="undefined"||be==="boolean")&&(O=null);var K=!1;if(O===null)K=!0;else switch(be){case"bigint":case"string":case"number":K=!0;break;case"object":switch(O.$$typeof){case t:case n:K=!0;break;case b:return K=O._init,P(K(O._payload),D,B,V,Z)}}if(K)return Z=Z(O),K=V===""?"."+de(O,0):V,_(Z)?(B="",K!=null&&(B=K.replace(ce,"$&/")+"/"),P(Z,D,B,"",function($){return $})):Z!=null&&(H(Z)&&(Z=I(Z,B+(Z.key==null||O&&O.key===Z.key?"":(""+Z.key).replace(ce,"$&/")+"/")+K)),D.push(Z)),1;K=0;var ge=V===""?".":V+":";if(_(O))for(var me=0;me<O.length;me++)V=O[me],be=ge+de(V,me),K+=P(V,D,B,be,Z);else if(me=y(O),typeof me=="function")for(O=me.call(O),me=0;!(V=O.next()).done;)V=V.value,be=ge+de(V,me++),K+=P(V,D,B,be,Z);else if(be==="object"){if(typeof O.then=="function")return P(se(O),D,B,V,Z);throw D=String(O),Error("Objects are not valid as a React child (found: "+(D==="[object Object]"?"object with keys {"+Object.keys(O).join(", ")+"}":D)+"). If you meant to render a collection of children, use an array instead.")}return K}function W(O,D,B){if(O==null)return O;var V=[],Z=0;return P(O,V,"","",function(be){return D.call(B,be,Z++)}),V}function oe(O){if(O._status===-1){var D=O._result;D=D(),D.then(function(B){(O._status===0||O._status===-1)&&(O._status=1,O._result=B)},function(B){(O._status===0||O._status===-1)&&(O._status=2,O._result=B)}),O._status===-1&&(O._status=0,O._result=D)}if(O._status===1)return O._result.default;throw O._result}var ae=typeof reportError=="function"?reportError:function(O){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var D=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof O=="object"&&O!==null&&typeof O.message=="string"?String(O.message):String(O),error:O});if(!window.dispatchEvent(D))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",O);return}console.error(O)},G={map:W,forEach:function(O,D,B){W(O,function(){D.apply(this,arguments)},B)},count:function(O){var D=0;return W(O,function(){D++}),D},toArray:function(O){return W(O,function(D){return D})||[]},only:function(O){if(!H(O))throw Error("React.Children.only expected to receive a single React element child.");return O}};return ze.Activity=v,ze.Children=G,ze.Component=E,ze.Fragment=a,ze.Profiler=c,ze.PureComponent=M,ze.StrictMode=o,ze.Suspense=p,ze.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=L,ze.__COMPILER_RUNTIME={__proto__:null,c:function(O){return L.H.useMemoCache(O)}},ze.cache=function(O){return function(){return O.apply(null,arguments)}},ze.cacheSignal=function(){return null},ze.cloneElement=function(O,D,B){if(O==null)throw Error("The argument must be a React element, but
Line truncated
|
||||||
|
* @license React
|
||||||
|
* scheduler.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var Ny;function k3(){return Ny||(Ny=1,(function(t){function n(P,W){var oe=P.length;P.push(W);e:for(;0<oe;){var ae=oe-1>>>1,G=P[ae];if(0<c(G,W))P[ae]=W,P[oe]=G,oe=ae;else break e}}function a(P){return P.length===0?null:P[0]}function o(P){if(P.length===0)return null;var W=P[0],oe=P.pop();if(oe!==W){P[0]=oe;e:for(var ae=0,G=P.length,O=G>>>1;ae<O;){var D=2*(ae+1)-1,B=P[D],V=D+1,Z=P[V];if(0>c(B,oe))V<G&&0>c(Z,B)?(P[ae]=Z,P[V]=oe,ae=V):(P[ae]=B,P[D]=oe,ae=D);else if(V<G&&0>c(Z,oe))P[ae]=Z,P[V]=oe,ae=V;else break e}}return W}function c(P,W){var oe=P.sortIndex-W.sortIndex;return oe!==0?oe:P.id-W.id}if(t.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var l=performance;t.unstable_now=function(){return l.now()}}else{var d=Date,m=d.now();t.unstable_now=function(){return d.now()-m}}var p=[],g=[],b=1,v=null,w=3,y=!1,C=!1,S=!1,N=!1,E=typeof setTimeout=="function"?setTimeout:null,T=typeof clearTimeout=="function"?clearTimeout:null,M=typeof setImmediate<"u"?setImmediate:null;function k(P){for(var W=a(g);W!==null;){if(W.callback===null)o(g);else if(W.startTime<=P)o(g),W.sortIndex=W.expirationTime,n(p,W);else break;W=a(g)}}function _(P){if(S=!1,k(P),!C)if(a(p)!==null)C=!0,R||(R=!0,q());else{var W=a(g);W!==null&&se(_,W.startTime-P)}}var R=!1,L=-1,ne=5,X=-1;function I(){return N?!0:!(t.unstable_now()-X<ne)}function H(){if(N=!1,R){var P=t.unstable_now();X=P;var W=!0;try{e:{C=!1,S&&(S=!1,T(L),L=-1),y=!0;var oe=w;try{t:{for(k(P),v=a(p);v!==null&&!(v.expirationTime>P&&I());){var ae=v.callback;if(typeof ae=="function"){v.callback=null,w=v.priorityLevel;var G=ae(v.expirationTime<=P);if(P=t.unstable_now(),typeof G=="function"){v.callback=G,k(P),W=!0;break t}v===a(p)&&o(p),k(P)}else o(p);v=a(p)}if(v!==null)W=!0;else{var O=a(g);O!==null&&se(_,O.startTime-P),W=!1}}break e}finally{v=null,w=oe,y=!1}W=void 0}}finally{W?q():R=!1}}}var q;if(typeof M=="function")q=function(){M(H)};else if(typeof MessageChannel<"u"){var ce=new MessageChannel,de=ce.port2;ce.port1.onmessage=H,q=function(){de.postMessage(null)}}else q=function(){E(H,0)};function se(P,W){L=E(function(){P(t.unstable_now())},W)}t.unstable_IdlePriority=5,t.unstable_ImmediatePriority=1,t.unstable_LowPriority=4,t.unstable_NormalPriority=3,t.unstable_Profiling=null,t.unstable_UserBlockingPriority=2,t.unstable_cancelCallback=function(P){P.callback=null},t.unstable_forceFrameRate=function(P){0>P||125<P?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):ne=0<P?Math.floor(1e3/P):5},t.unstable_getCurrentPriorityLevel=function(){return w},t.unstable_next=function(P){switch(w){case 1:case 2:case 3:var W=3;break;default:W=w}var oe=w;w=W;try{return P()}finally{w=oe}},t.unstable_requestPaint=function(){N=!0},t.unstable_runWithPriority=function(P,W){switch(P){case 1:case 2:case 3:case 4:case 5:break;default:P=3}var oe=w;w=P;try{return W()}finally{w=oe}},t.unstable_scheduleCallback=function(P,W,oe){var ae=t.unstable_now();switch(typeof oe=="object"&&oe!==null?(oe=oe.delay,oe=typeof oe=="number"&&0<oe?ae+oe:ae):oe=ae,P){case 1:var G=-1;break;case 2:G=250;break;case 5:G=1073741823;break;case 4:G=1e4;break;default:G=5e3}return G=oe+G,P={id:b++,callback:W,priorityLevel:P,startTime:oe,expirationTime:G,sortIndex:-1},oe>ae?(P.sortIndex=oe,n(g,P),a(p)===null&&P===a(g)&&(S?(T(L),L=-1):S=!0,se(_,oe-ae))):(P.sortIndex=G,n(p,P),C||y||(C=!0,R||(R=!0,q()))),P},t.unstable_shouldYield=I,t.unstable_wrapCallback=function(P){var W=w;return function(){var oe=w;w=W;try{return P.apply(this,arguments)}finally{w=oe}}}})(gp)),gp}var Ey;function T3(){return Ey||(Ey=1,hp.exports=k3()),hp.exports}var xp={exports:{}},hn={};/**
|
||||||
|
* @license React
|
||||||
|
* react-dom.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var ky;function M3(){if(ky)return hn;ky=1;var t=Xh();function n(p){var g="https://react.dev/errors/"+p;if(1<arguments.length){g+="?args[]="+encodeURIComponent(arguments[1]);for(var b=2;b<arguments.length;b++)g+="&args[]="+encodeURIComponent(arguments[b])}return"Minified React error #"+p+"; visit "+g+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function a(){}var o={d:{f:a,r:function(){throw Error(n(522))},D:a,C:a,L:a,m:a,X:a,S:a,M:a},p:0,findDOMNode:null},c=Symbol.for("react.portal");function l(p,g,b){var v=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:c,key:v==null?null:""+v,children:p,containerInfo:g,implementation:b}}var d=t.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function m(p,g){if(p==="font")return"";if(typeof g=="string")return g==="use-credentials"?g:""}return hn.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=o,hn.createPortal=function(p,g){var b=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!g||g.nodeType!==1&&g.nodeType!==9&&g.nodeType!==11)throw Error(n(299));return l(p,g,null,b)},hn.flushSync=function(p){var g=d.T,b=o.p;try{if(d.T=null,o.p=2,p)return p()}finally{d.T=g,o.p=b,o.d.f()}},hn.preconnect=function(p,g){typeof p=="string"&&(g?(g=g.crossOrigin,g=typeof g=="string"?g==="use-credentials"?g:"":void 0):g=null,o.d.C(p,g))},hn.prefetchDNS=function(p){typeof p=="string"&&o.d.D(p)},hn.preinit=function(p,g){if(typeof p=="string"&&g&&typeof g.as=="string"){var b=g.as,v=m(b,g.crossOrigin),w=typeof g.integrity=="string"?g.integrity:void 0,y=typeof g.fetchPriority=="string"?g.fetchPriority:void 0;b==="style"?o.d.S(p,typeof g.precedence=="string"?g.precedence:void 0,{crossOrigin:v,integrity:w,fetchPriority:y}):b==="script"&&o.d.X(p,{crossOrigin:v,integrity:w,fetchPriority:y,nonce:typeof g.nonce=="string"?g.nonce:void 0})}},hn.preinitModule=function(p,g){if(typeof p=="string")if(typeof g=="object"&&g!==null){if(g.as==null||g.as==="script"){var b=m(g.as,g.crossOrigin);o.d.M(p,{crossOrigin:b,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0})}}else g==null&&o.d.M(p)},hn.preload=function(p,g){if(typeof p=="string"&&typeof g=="object"&&g!==null&&typeof g.as=="string"){var b=g.as,v=m(b,g.crossOrigin);o.d.L(p,b,{crossOrigin:v,integrity:typeof g.integrity=="string"?g.integrity:void 0,nonce:typeof g.nonce=="string"?g.nonce:void 0,type:typeof g.type=="string"?g.type:void 0,fetchPriority:typeof g.fetchPriority=="string"?g.fetchPriority:void 0,referrerPolicy:typeof g.referrerPolicy=="string"?g.referrerPolicy:void 0,imageSrcSet:typeof g.imageSrcSet=="string"?g.imageSrcSet:void 0,imageSizes:typeof g.imageSizes=="string"?g.imageSizes:void 0,media:typeof g.media=="string"?g.media:void 0})}},hn.preloadModule=function(p,g){if(typeof p=="string")if(g){var b=m(g.as,g.crossOrigin);o.d.m(p,{as:typeof g.as=="string"&&g.as!=="script"?g.as:void 0,crossOrigin:b,integrity:typeof g.integrity=="string"?g.integrity:void 0})}else o.d.m(p)},hn.requestFormReset=function(p){o.d.r(p)},hn.unstable_batchedUpdates=function(p,g){return p(g)},hn.useFormState=function(p,g,b){return d.H.useFormState(p,g,b)},hn.useFormStatus=function(){return d.H.useHostTransitionStatus()},hn.version="19.2.8",hn}var Ty;function o2(){if(Ty)return xp.exports;Ty=1;function t(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(t)}catch(n){console.error(n)}}return t(),xp.exports=M3(),xp.exports}/**
|
||||||
|
* @license React
|
||||||
|
* react-dom-client.production.js
|
||||||
|
*
|
||||||
|
* Copyright (c) Meta Platforms, Inc. and affiliates.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE file in the root directory of this source tree.
|
||||||
|
*/var My;function R3(){if(My)return Nl;My=1;var t=T3(),n=Xh(),a=o2();function o(e){var r="https://react.dev/errors/"+e;if(1<arguments.length){r+="?args[]="+encodeURIComponent(arguments[1]);for(var i=2;i<arguments.length;i++)r+="&args[]="+encodeURIComponent(arguments[i])}return"Minified React error #"+e+"; visit "+r+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function c(e){return!(!e||e.nodeType!==1&&e.nodeType!==9&&e.nodeType!==11)}function l(e){var r=e,i=e;if(e.alternate)for(;r.return;)r=r.return;else{e=r;do r=e,(r.flags&4098)!==0&&(i=r.return),e=r.return;while(e)}return r.tag===3?i:null}function d(e){if(e.tag===13){var r=e.memoizedState;if(r===null&&(e=e.alternate,e!==null&&(r=e.memoizedState)),r!==null)return r.dehydrated}return null}function m(e){if(e.tag===31){var r=e.memoizedState;if(r===null&&(e=e.alternate,e!==null&&(r=e.memoizedState)),r!==null)return r.dehydrated}return null}function p(e){if(l(e)!==e)throw Error(o(188))}function g(e){var r=e.alternate;if(!r){if(r=l(e),r===null)throw Error(o(188));return r!==e?null:e}for(var i=e,u=r;;){var h=i.return;if(h===null)break;var x=h.alternate;if(x===null){if(u=h.return,u!==null){i=u;continue}break}if(h.child===x.child){for(x=h.child;x;){if(x===i)return p(h),e;if(x===u)return p(h),r;x=x.sibling}throw Error(o(188))}if(i.return!==u.return)i=h,u=x;else{for(var j=!1,A=h.child;A;){if(A===i){j=!0,i=h,u=x;break}if(A===u){j=!0,u=h,i=x;break}A=A.sibling}if(!j){for(A=x.child;A;){if(A===i){j=!0,i=x,u=h;break}if(A===u){j=!0,u=x,i=h;break}A=A.sibling}if(!j)throw Error(o(189))}}if(i.alternate!==u)throw Error(o(190))}if(i.tag!==3)throw Error(o(188));return i.stateNode.current===i?e:r}function b(e){var r=e.tag;if(r===5||r===26||r===27||r===6)return e;for(e=e.child;e!==null;){if(r=b(e),r!==null)return r;e=e.sibling}return null}var v=Object.assign,w=Symbol.for("react.element"),y=Symbol.for("react.transitional.element"),C=Symbol.for("react.portal"),S=Symbol.for("react.fragment"),N=Symbol.for("react.strict_mode"),E=Symbol.for("react.profiler"),T=Symbol.for("react.consumer"),M=Symbol.for("react.context"),k=Symbol.for("react.forward_ref"),_=Symbol.for("react.suspense"),R=Symbol.for("react.suspense_list"),L=Symbol.for("react.memo"),ne=Symbol.for("react.lazy"),X=Symbol.for("react.activity"),I=Symbol.for("react.memo_cache_sentinel"),H=Symbol.iterator;function q(e){return e===null||typeof e!="object"?null:(e=H&&e[H]||e["@@iterator"],typeof e=="function"?e:null)}var ce=Symbol.for("react.client.reference");function de(e){if(e==null)return null;if(typeof e=="function")return e.$$typeof===ce?null:e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case S:return"Fragment";case E:return"Profiler";case N:return"StrictMode";case _:return"Suspense";case R:return"SuspenseList";case X:return"Activity"}if(typeof e=="object")switch(e.$$typeof){case C:return"Portal";case M:return e.displayName||"Context";case T:return(e._context.displayName||"Context")+".Consumer";case k:var r=e.render;return e=e.displayName,e||(e=r.displayName||r.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case L:return r=e.displayName||null,r!==null?r:de(e.type)||"Memo";case ne:r=e._payload,e=e._init;try{return de(e(r))}catch{}}return null}var se=Array.isArray,P=n.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,W=a.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,oe={pending:!1,data:null,method:null,action:null},ae=[],G=-1;function O(e){return{current:e}}function D(e){0>G||(e.current=ae[G],ae[G]=null,G--)}function B(e,r){G++,ae[G]=e.current,e.current=r}var V=O(null),Z=O(null),be=O(null),K=O(null);function ge(e,r){switch(B(be,r),B(Z,e),B(V,null),r.nodeType){case 9:case 11:e=(e=r.documentElement)&&(e=e.namespaceURI)?Bv(e):0;break;default:if(e=r.tagName,r=r.namespaceURI)r=Bv(r),e=$v(r,e);else switch(e){case"svg":e=1;break;case"math":e=2;break;default:e=0}}D(V),B(V,e)}function me(){D(V),D(Z),D(be)}function $(e){e.memoizedState!==null&&B(K,e);var r=V.current,i=$v(r,e.type);r!==i&&(B(Z,e),B(V,i))}function ie(e){Z.current===e&&(D(V),D(Z)),K.current===e&&(D(K),yl._currentValue=oe)}var ye,Se;function Be(e){if(ye===void 0)try{throw Error()}catch(i){var r=i.stack.trim().match(/\n( *(at )?)/);ye=r&&r[1]||"",Se=-1<i.stack.indexOf(`
|
||||||
|
at`)?" (<anonymous>)":-1<i.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||||
|
`+ye+e+Se}var Ke=!1;function We(e,r){if(!e||Ke)return"";Ke=!0;var i=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={DetermineComponentFrameRoot:function(){try{if(r){var he=function(){throw Error()};if(Object.defineProperty(he.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(he,[])}catch(re){var ee=re}Reflect.construct(e,[],he)}else{try{he.call()}catch(re){ee=re}e.call(he.prototype)}}else{try{throw Error()}catch(re){ee=re}(he=e())&&typeof he.catch=="function"&&he.catch(function(){})}}catch(re){if(re&&ee&&typeof re.stack=="string")return[re.stack,ee.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var h=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");h&&h.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var x=u.DetermineComponentFrameRoot(),j=x[0],A=x[1];if(j&&A){var z=j.split(`
|
||||||
|
`),J=A.split(`
|
||||||
|
`);for(h=u=0;u<z.length&&!z[u].includes("DetermineComponentFrameRoot");)u++;for(;h<J.length&&!J[h].includes("DetermineComponentFrameRoot");)h++;if(u===z.length||h===J.length)for(u=z.length-1,h=J.length-1;1<=u&&0<=h&&z[u]!==J[h];)h--;for(;1<=u&&0<=h;u--,h--)if(z[u]!==J[h]){if(u!==1||h!==1)do if(u--,h--,0>h||z[u]!==J[h]){var le=`
|
||||||
|
`+z[u].replace(" at new "," at ");return e.displayName&&le.includes("<anonymous>")&&(le=le.replace("<anonymous>",e.displayName)),le}while(1<=u&&0<=h);break}}}finally{Ke=!1,Error.prepareStackTrace=i}return(i=e?e.displayName||e.name:"")?Be(i):""}function qt(e,r){switch(e.tag){case 26:case 27:case 5:return Be(e.type);case 16:return Be("Lazy");case 13:return e.child!==r&&r!==null?Be("Suspense Fallback"):Be("Suspense");case 19:return Be("SuspenseList");case 0:case 15:return We(e.type,!1);case 11:return We(e.type.render,!1);case 1:return We(e.type,!0);case 31:return Be("Activity");default:return""}}function vn(e){try{var r="",i=null;do r+=qt(e,i),i=e,e=e.return;while(e);return r}catch(u){return`
|
||||||
|
Error generating stack: `+u.message+`
|
||||||
|
`+u.stack}}var Xn=Object.prototype.hasOwnProperty,br=t.unstable_scheduleCallback,yn=t.unstable_cancelCallback,wn=t.unstable_shouldYield,Zn=t.unstable_requestPaint,Kt=t.unstable_now,po=t.unstable_getCurrentPriorityLevel,Pt=t.unstable_ImmediatePriority,Qn=t.unstable_UserBlockingPriority,Da=t.unstable_NormalPriority,Es=t.unstable_LowPriority,qr=t.unstable_IdlePriority,ho=t.log,Kr=t.unstable_setDisableYieldValue,Dn=null,yt=null;function On(e){if(typeof ho=="function"&&Kr(e),yt&&typeof yt.setStrictMode=="function")try{yt.setStrictMode(Dn,e)}catch{}}var Rt=Math.clz32?Math.clz32:go,Ai=Math.log,_i=Math.LN2;function go(e){return e>>>=0,e===0?32:31-(Ai(e)/_i|0)|0}var Wr=256,Oa=262144,Ar=4194304;function Wt(e){var r=e&42;if(r!==0)return r;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function La(e,r,i){var u=e.pendingLanes;if(u===0)return 0;var h=0,x=e.suspendedLanes,j=e.pingedLanes;e=e.warmLanes;var A=u&134217727;return A!==0?(u=A&~x,u!==0?h=Wt(u):(j&=A,j!==0?h=Wt(j):i||(i=A&~e,i!==0&&(h=Wt(i))))):(A=u&~x,A!==0?h=Wt(A):j!==0?h=Wt(j):i||(i=u&~e,i!==0&&(h=Wt(i)))),h===0?0:r!==0&&r!==h&&(r&x)===0&&(x=h&-h,i=r&-r,x>=i||x===32&&(i&4194048)!==0)?r:h}function De(e,r){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&r)===0}function rt(e,r){switch(e){case 1:case 2:case 4:case 8:case 64:return r+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return r+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function lt(){var e=Ar;return Ar<<=1,(Ar&62914560)===0&&(Ar=4194304),e}function rn(e){for(var r=[],i=0;31>i;i++)r.push(e);return r}function It(e,r){e.pendingLanes|=r,r!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function Jn(e,r,i,u,h,x){var j=e.pendingLanes;e.pendingLanes=i,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=i,e.entangledLanes&=i,e.errorRecoveryDisabledLanes&=i,e.shellSuspendCounter=0;var A=e.entanglements,z=e.expirationTimes,J=e.hiddenUpdates;for(i=j&~i;0<i;){var le=31-Rt(i),he=1<<le;A[le]=0,z[le]=-1;var ee=J[le];if(ee!==null)for(J[le]=null,le=0;le<ee.length;le++){var re=ee[le];re!==null&&(re.lane&=-536870913)}i&=~he}u!==0&&er(e,u,0),x!==0&&h===0&&e.tag!==0&&(e.suspendedLanes|=x&~(j&~r))}function er(e,r,i){e.pendingLanes|=r,e.suspendedLanes&=~r;var u=31-Rt(r);e.entangledLanes|=r,e.entanglements[u]=e.entanglements[u]|1073741824|i&261930}function bt(e,r){var i=e.entangledLanes|=r;for(e=e.entanglements;i;){var u=31-Rt(i),h=1<<u;h&r|e[u]&r&&(e[u]|=r),i&=~h}}function Ln(e,r){var i=r&-r;return i=(i&42)!==0?1:Di(i),(i&(e.suspendedLanes|r))!==0?0:i}function Di(e){switch(e){case 2:e=1;break;case 8:e=4;break;case 32:e=16;break;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:case 4194304:case 8388608:case 16777216:case 33554432:e=128;break;case 268435456:e=134217728;break;default:e=0}return e}function Oi(e){return e&=-e,2<e?8<e?(e&134217727)!==0?32:268435456:8:2}function Li(){var e=W.p;return e!==0?e:(e=window.event,e===void 0?32:uy(e.type))}function zi(e,r){var i=W.p;try{return W.p=e,r()}finally{W.p=i}}var Y=Math.random().toString(36).slice(2),te="__reactFiber$"+Y,je="__reactProps$"+Y,we="__reactContainer$"+Y,Le="__reactEvents$"+Y,Xe="__reactListeners$"+Y,vt="__reactHandles$"+Y,gt="__reactResources$"+Y,jn="__reactMarker$"+Y;function mn(e){delete e[te],delete e[je],delete e[Le],delete e[Xe],delete e[vt]}function an(e){var r=e[te];if(r)return r;for(var i=e.parentNode;i;){if(r=i[we]||i[te]){if(i=r.alternate,r.child!==null||i!==null&&i.child!==null)for(e=Kv(e);e!==null;){if(i=e[te])return i;e=Kv(e)}return r}e=i,i=e.parentNode}return null}function tr(e){if(e=e[te]||e[we]){var r=e.tag;if(r===5||r===6||r===13||r===31||r===26||r===27||r===3)return e}return null}function _r(e){var r=e.tag;if(r===5||r===26||r===27||r===6)return e.stateNode;throw Error(o(33))}function Xr(e){var r=e[gt];return r||(r=e[gt]={hoistableStyles:new Map,hoistableScripts:new Map}),r}function Xt(e){e[jn]=!0}var Tx=new Set,Mx={};function ks(e,r){xo(e,r),xo(e+"Capture",r)}function xo(e,r){for(Mx[e]=r,e=0;e<r.length;e++)Tx.add(r[e])}var f5=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u200D\\u
Line truncated
|
||||||
|
`).replace(G4,"")}function Iv(e,r){return r=Pv(r),Pv(e)===r}function pt(e,r,i,u,h,x){switch(i){case"children":typeof u=="string"?r==="body"||r==="textarea"&&u===""||vo(e,u):(typeof u=="number"||typeof u=="bigint")&&r!=="body"&&vo(e,""+u);break;case"className":gc(e,"class",u);break;case"tabIndex":gc(e,"tabindex",u);break;case"dir":case"role":case"viewBox":case"width":case"height":gc(e,i,u);break;case"style":Ix(e,u,x);break;case"data":if(r!=="object"){gc(e,"data",u);break}case"src":case"href":if(u===""&&(r!=="a"||i!=="href")){e.removeAttribute(i);break}if(u==null||typeof u=="function"||typeof u=="symbol"||typeof u=="boolean"){e.removeAttribute(i);break}u=bc(""+u),e.setAttribute(i,u);break;case"action":case"formAction":if(typeof u=="function"){e.setAttribute(i,"javascript:throw new Error('A React form was unexpectedly submitted. If you called form.submit() manually, consider using form.requestSubmit() instead. If you\\'re trying to use event.stopPropagation() in a submit event handler, consider also calling event.preventDefault().')");break}else typeof x=="function"&&(i==="formAction"?(r!=="input"&&pt(e,r,"name",h.name,h,null),pt(e,r,"formEncType",h.formEncType,h,null),pt(e,r,"formMethod",h.formMethod,h,null),pt(e,r,"formTarget",h.formTarget,h,null)):(pt(e,r,"encType",h.encType,h,null),pt(e,r,"method",h.method,h,null),pt(e,r,"target",h.target,h,null)));if(u==null||typeof u=="symbol"||typeof u=="boolean"){e.removeAttribute(i);break}u=bc(""+u),e.setAttribute(i,u);break;case"onClick":u!=null&&(e.onclick=Qr);break;case"onScroll":u!=null&&Ve("scroll",e);break;case"onScrollEnd":u!=null&&Ve("scrollend",e);break;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(o(61));if(i=u.__html,i!=null){if(h.children!=null)throw Error(o(60));e.innerHTML=i}}break;case"multiple":e.multiple=u&&typeof u!="function"&&typeof u!="symbol";break;case"muted":e.muted=u&&typeof u!="function"&&typeof u!="symbol";break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"defaultValue":case"defaultChecked":case"innerHTML":case"ref":break;case"autoFocus":break;case"xlinkHref":if(u==null||typeof u=="function"||typeof u=="boolean"||typeof u=="symbol"){e.removeAttribute("xlink:href");break}i=bc(""+u),e.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",i);break;case"contentEditable":case"spellCheck":case"draggable":case"value":case"autoReverse":case"externalResourcesRequired":case"focusable":case"preserveAlpha":u!=null&&typeof u!="function"&&typeof u!="symbol"?e.setAttribute(i,""+u):e.removeAttribute(i);break;case"inert":case"allowFullScreen":case"async":case"autoPlay":case"controls":case"default":case"defer":case"disabled":case"disablePictureInPicture":case"disableRemotePlayback":case"formNoValidate":case"hidden":case"loop":case"noModule":case"noValidate":case"open":case"playsInline":case"readOnly":case"required":case"reversed":case"scoped":case"seamless":case"itemScope":u&&typeof u!="function"&&typeof u!="symbol"?e.setAttribute(i,""):e.removeAttribute(i);break;case"capture":case"download":u===!0?e.setAttribute(i,""):u!==!1&&u!=null&&typeof u!="function"&&typeof u!="symbol"?e.setAttribute(i,u):e.removeAttribute(i);break;case"cols":case"rows":case"size":case"span":u!=null&&typeof u!="function"&&typeof u!="symbol"&&!isNaN(u)&&1<=u?e.setAttribute(i,u):e.removeAttribute(i);break;case"rowSpan":case"start":u==null||typeof u=="function"||typeof u=="symbol"||isNaN(u)?e.removeAttribute(i):e.setAttribute(i,u);break;case"popover":Ve("beforetoggle",e),Ve("toggle",e),hc(e,"popover",u);break;case"xlinkActuate":Zr(e,"http://www.w3.org/1999/xlink","xlink:actuate",u);break;case"xlinkArcrole":Zr(e,"http://www.w3.org/1999/xlink","xlink:arcrole",u);break;case"xlinkRole":Zr(e,"http://www.w3.org/1999/xlink","xlink:role",u);break;case"xlinkShow":Zr(e,"http://www.w3.org/1999/xlink","xlink:show",u);break;case"xlinkTitle":Zr(e,"http://www.w3.org/1999/xlink","xlink:title",u);break;case"xlinkType":Zr(e,"http://www.w3.org/1999/xlink","xlink:type",u);break;case"xmlBase":Zr(e,"http://www.w3.org/XML/1998/namespace","xml:base",u);break;case"xmlLang":Zr(e,"http://www.w3.org/XML/1998/namespace","xml:lang",u);break;case"xmlSpace":Zr(e,"http://www.w3.org/XML/1998/namespace","xml:space",u);break;case"is":hc(e,"is",u);break;case"innerText":case"textContent":break;default:(!(2<i.length)||i[0]!=="o"&&i[0]!=="O"||i[1]!=="n"&&i[1]!=="N")&&(i=x5.get(i)||i,hc(e,i,u))}}function qm(e,r,i,u,h,x){switch(i){case"style":Ix(e,u,x);break;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(o(61));if(i=u.__html,i!=null){if(h.children!=null)throw Error(o(60));e.innerHTML=i}}break;case"children":typeof u=="string"?vo(e,u):(typeof u=="number"||typeof u=="bigint")&&vo(e,""+u);break;case"onScroll":u!=null&&Ve("scroll",e);break;case"onScrollEnd":u!=null&&Ve("scrollend",e);break;case"onClick":u!=null&&(e.onclick=Qr);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inne
Line truncated
|
||||||
|
* react-router v7.18.2
|
||||||
|
*
|
||||||
|
* Copyright (c) Remix Software Inc.
|
||||||
|
*
|
||||||
|
* This source code is licensed under the MIT license found in the
|
||||||
|
* LICENSE.md file in the root directory of this source tree.
|
||||||
|
*
|
||||||
|
* @license MIT
|
||||||
|
*/var Zh=/^(?:[a-z][a-z0-9+.-]*:|[\\/]{2})/i,i2=/^[\\/]{2}/;function D3(t,n){return n+t.replace(/\\/g,"/")}var Ay="popstate";function _y(t){return typeof t=="object"&&t!=null&&"pathname"in t&&"search"in t&&"hash"in t&&"state"in t&&"key"in t}function O3(t={}){function n(o,c){var g;let l=(g=c.state)==null?void 0:g.masked,{pathname:d,search:m,hash:p}=l||o.location;return Qp("",{pathname:d,search:m,hash:p},c.state&&c.state.usr||null,c.state&&c.state.key||"default",l?{pathname:o.location.pathname,search:o.location.search,hash:o.location.hash}:void 0)}function a(o,c){return typeof c=="string"?c:Il(c)}return z3(n,a,null,t)}function kt(t,n){if(t===!1||t===null||typeof t>"u")throw new Error(n)}function pr(t,n){if(!t){typeof console<"u"&&console.warn(n);try{throw new Error(n)}catch{}}}function L3(){return Math.random().toString(36).substring(2,10)}function Dy(t,n){return{usr:t.state,key:t.key,idx:n,masked:t.mask?{pathname:t.pathname,search:t.search,hash:t.hash}:void 0}}function Qp(t,n,a=null,o,c){return{pathname:typeof t=="string"?t:t.pathname,search:"",hash:"",...typeof n=="string"?hi(n):n,state:a,key:n&&n.key||o||L3(),mask:c}}function Il({pathname:t="/",search:n="",hash:a=""}){return n&&n!=="?"&&(t+=n.charAt(0)==="?"?n:"?"+n),a&&a!=="#"&&(t+=a.charAt(0)==="#"?a:"#"+a),t}function hi(t){let n={};if(t){let a=t.indexOf("#");a>=0&&(n.hash=t.substring(a),t=t.substring(0,a));let o=t.indexOf("?");o>=0&&(n.search=t.substring(o),t=t.substring(0,o)),t&&(n.pathname=t)}return n}function z3(t,n,a,o={}){let{window:c=document.defaultView,v5Compat:l=!1}=o,d=c.history,m="POP",p=null,g=b();g==null&&(g=0,d.replaceState({...d.state,idx:g},""));function b(){return(d.state||{idx:null}).idx}function v(){m="POP";let N=b(),E=N==null?null:N-g;g=N,p&&p({action:m,location:S.location,delta:E})}function w(N,E){m="PUSH";let T=_y(N)?N:Qp(S.location,N,E);g=b()+1;let M=Dy(T,g),k=S.createHref(T.mask||T);try{d.pushState(M,"",k)}catch(_){if(_ instanceof DOMException&&_.name==="DataCloneError")throw _;c.location.assign(k)}l&&p&&p({action:m,location:S.location,delta:1})}function y(N,E){m="REPLACE";let T=_y(N)?N:Qp(S.location,N,E);g=b();let M=Dy(T,g),k=S.createHref(T.mask||T);d.replaceState(M,"",k),l&&p&&p({action:m,location:S.location,delta:0})}function C(N){return P3(c,N)}let S={get action(){return m},get location(){return t(c,d)},listen(N){if(p)throw new Error("A history only accepts one active listener");return c.addEventListener(Ay,v),p=N,()=>{c.removeEventListener(Ay,v),p=null}},createHref(N){return n(c,N)},createURL:C,encodeLocation(N){let E=C(N);return{pathname:E.pathname,search:E.search,hash:E.hash}},push:w,replace:y,go(N){return d.go(N)}};return S}function P3(t,n,a=!1){let o="http://localhost";t&&(o=t.location.origin!=="null"?t.location.origin:t.location.href),kt(o,"No window.location.(origin|href) available to create URL");let c=typeof n=="string"?n:Il(n);return c=c.replace(/ $/,"%20"),!a&&i2.test(c)&&(c=o+c),new URL(c,o)}function l2(t,n,a="/"){return I3(t,n,a,!1)}function I3(t,n,a,o,c){let l=typeof n=="string"?hi(n):n,d=Na(l.pathname||"/",a);if(d==null)return null;let m=U3(t),p=null,g=X3(d);for(let b=0;p==null&&b<m.length;++b)p=W3(m[b],g,o);return p}function U3(t){let n=c2(t);return B3(n),n}function c2(t,n=[],a=[],o="",c=!1){let l=(d,m,p=c,g)=>{let b={relativePath:g===void 0?d.path||"":g,caseSensitive:d.caseSensitive===!0,childrenIndex:m,route:d};if(b.relativePath.startsWith("/")){if(!b.relativePath.startsWith(o)&&p)return;kt(b.relativePath.startsWith(o),`Absolute route path "${b.relativePath}" nested under path "${o}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),b.relativePath=b.relativePath.slice(o.length)}let v=Sr([o,b.relativePath]),w=a.concat(b);d.children&&d.children.length>0&&(kt(d.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${v}".`),c2(d.children,n,w,v,p)),!(d.path==null&&!d.index)&&n.push({path:v,score:q3(v,d.index),routesMeta:w.map((y,C)=>{let[S,N]=f2(y.relativePath,y.caseSensitive,C===w.length-1);return{...y,matcher:S,compiledParams:N}})})};return t.forEach((d,m)=>{var p;if(d.path===""||!((p=d.path)!=null&&p.includes("?")))l(d,m);else for(let g of u2(d.path))l(d,m,!0,g)}),n}function u2(t){let n=t.split("/");if(n.length===0)return[];let[a,...o]=n,c=a.endsWith("?"),l=a.replace(/\?$/,"");if(o.length===0)return c?[l,""]:[l];let d=u2(o.join("/")),m=[];return m.push(...d.map(p=>p===""?l:[l,p].join("/"))),c&&m.push(...d),m.map(p=>t.startsWith("/")&&p===""?"/":p)}function B3(t){t.sort((n,a)=>n.score!==a.score?a.score-n.score:K3(n.routesMeta.map(o=>o.childrenIndex),a.routesMeta.map(o=>o.childrenIndex)))}var $3=/^:[\w-]+$/,H3=3,G3=2,V3=1,F3=10,Y3=-2,Oy=t=>t==="*";function q3(t,n){let a=t.split("/"),o=a.length;return a.some(Oy)&&(o+=Y3),n&&(o+=G3),a.filter(c=>!Oy(c)).reduce((c,l)=>c+($3.test(l)?H3:l===""?V3:F3),o)}function K3(t,n){return t.length===n.length&&t.slice(0,-1).every((o,c)=>o===n[c])?t[t.length-1]-n[n.length-1]:0}fuLine truncated
|
||||||
|
|
||||||
|
Please change the parent <Route path="${E}"> to <Route path="${E==="/"?"*":`${E}/*`}">.`)}let b=Kn(),v;if(n){let E=typeof n=="string"?hi(n):n;kt(p==="/"||((N=E.pathname)==null?void 0:N.startsWith(p)),`When overriding the location using \`<Routes location>\` or \`useRoutes(routes, location)\`, the location pathname must begin with the portion of the URL pathname that was matched by all parent routes. The current pathname base is "${p}" but pathname "${E.pathname}" was given in the \`location\` prop.`),v=E}else v=b;let w=v.pathname||"/",y=w;if(p!=="/"){let E=p.replace(/^\//,"").split("/");y="/"+w.replace(/^\//,"").split("/").slice(E.length).join("/")}let C=a&&a.state.matches.length?a.state.matches.map(E=>Object.assign(E,{route:a.manifest[E.route.id]||E.route})):l2(t,{pathname:y});pr(g||C!=null,`No routes matched location "${v.pathname}${v.search}${v.hash}" `),pr(C==null||C[C.length-1].route.element!==void 0||C[C.length-1].route.Component!==void 0||C[C.length-1].route.lazy!==void 0,`Matched leaf route at location "${v.pathname}${v.search}${v.hash}" does not have an element or Component. This means it will render an <Outlet /> with a null value by default resulting in an "empty" page.`);let S=SN(C&&C.map(E=>Object.assign({},E,{params:Object.assign({},d,E.params),pathname:Sr([p,o.encodeLocation?o.encodeLocation(E.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:E.pathname]),pathnameBase:E.pathnameBase==="/"?p:Sr([p,o.encodeLocation?o.encodeLocation(E.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:E.pathnameBase])})),c,a);return n&&S?f.createElement(Jl.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",mask:void 0,...v},navigationType:"POP"}},S):S}function yN(){let t=MN(),n=rN(t)?`${t.status} ${t.statusText}`:t instanceof Error?t.message:JSON.stringify(t),a=t instanceof Error?t.stack:null,o="rgba(200,200,200, 0.5)",c={padding:"0.5rem",backgroundColor:o},l={padding:"2px 4px",backgroundColor:o},d=null;return console.error("Error handled by React Router default ErrorBoundary:",t),d=f.createElement(f.Fragment,null,f.createElement("p",null,"💿 Hey developer 👋"),f.createElement("p",null,"You can provide a way better UX than this when your app throws errors by providing your own ",f.createElement("code",{style:l},"ErrorBoundary")," or"," ",f.createElement("code",{style:l},"errorElement")," prop on your route.")),f.createElement(f.Fragment,null,f.createElement("h2",null,"Unexpected Application Error!"),f.createElement("h3",{style:{fontStyle:"italic"}},n),a?f.createElement("pre",{style:c},a):null,d)}var wN=f.createElement(yN,null),C2=class extends f.Component{constructor(t){super(t),this.state={location:t.location,revalidation:t.revalidation,error:t.error}}static getDerivedStateFromError(t){return{error:t}}static getDerivedStateFromProps(t,n){return n.location!==t.location||n.revalidation!=="idle"&&t.revalidation==="idle"?{error:t.error,location:t.location,revalidation:t.revalidation}:{error:t.error!==void 0?t.error:n.error,location:n.location,revalidation:t.revalidation||n.revalidation}}componentDidCatch(t,n){this.props.onError?this.props.onError(t,n):console.error("React Router caught the following error during render",t)}render(){let t=this.state.error;if(this.context&&typeof t=="object"&&t&&"digest"in t&&typeof t.digest=="string"){const a=pN(t.digest);a&&(t=a)}let n=t!==void 0?f.createElement(Tr.Provider,{value:this.props.routeContext},f.createElement(Jh.Provider,{value:t,children:this.props.component})):this.props.children;return this.context?f.createElement(jN,{error:t},n):n}};C2.contextType=x2;var vp=new WeakMap;function jN({children:t,error:n}){let{basename:a}=f.useContext(qn);if(typeof n=="object"&&n&&"digest"in n&&typeof n.digest=="string"){let o=mN(n.digest);if(o){let c=vp.get(n);if(c)throw c;let l=h2(o.location,a),d=l.absoluteURL||l.to;if(iN(d))throw new Error("Invalid redirect location");if(p2&&!vp.get(n))if(l.isExternal||o.reloadDocument)window.location.href=d;else{const m=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(l.to,{replace:o.replace}));throw vp.set(n,m),m}return f.createElement("meta",{httpEquiv:"refresh",content:`0;url=${d}`})}}return t}function CN({routeContext:t,match:n,children:a}){let o=f.useContext(gi);return o&&o.static&&o.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(o.staticContext._deepestRenderedBoundaryId=n.route.id),f.createElement(Tr.Provider,{value:t},a)}function SN(t,n=[],a){let o=a==null?void 0:a.state;if(t==null){if(!o)return null;if(o.errors)t=o.matches;else if(n.length===0&&!o.initialized&&o.matches.length>0)t=o.matches;else return null}let c=t,l=o==null?void 0:o.errors;if(l!=null){let b=c.findIndex(v=>v.route.id&&(l==null?void 0:l[v.route.id])!==void 0);kt(b>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(l).join(",")}`),c=c.slice(0,Math.min(c.length,b+1))}let d=!1,m=-1;if(a&&o){d=o.renderFallback;for(let b=0;bLine truncated
|
||||||
|
.`.concat(Bk,` {
|
||||||
|
overflow: hidden `).concat(o,`;
|
||||||
|
padding-right: `).concat(m,"px ").concat(o,`;
|
||||||
|
}
|
||||||
|
body[`).concat(li,`] {
|
||||||
|
overflow: hidden `).concat(o,`;
|
||||||
|
overscroll-behavior: contain;
|
||||||
|
`).concat([n&&"position: relative ".concat(o,";"),a==="margin"&&`
|
||||||
|
padding-left: `.concat(c,`px;
|
||||||
|
padding-top: `).concat(l,`px;
|
||||||
|
padding-right: `).concat(d,`px;
|
||||||
|
margin-left:0;
|
||||||
|
margin-top:0;
|
||||||
|
margin-right: `).concat(m,"px ").concat(o,`;
|
||||||
|
`),a==="padding"&&"padding-right: ".concat(m,"px ").concat(o,";")].filter(Boolean).join(""),`
|
||||||
|
}
|
||||||
|
|
||||||
|
.`).concat(Zu,` {
|
||||||
|
right: `).concat(m,"px ").concat(o,`;
|
||||||
|
}
|
||||||
|
|
||||||
|
.`).concat(Qu,` {
|
||||||
|
margin-right: `).concat(m,"px ").concat(o,`;
|
||||||
|
}
|
||||||
|
|
||||||
|
.`).concat(Zu," .").concat(Zu,` {
|
||||||
|
right: 0 `).concat(o,`;
|
||||||
|
}
|
||||||
|
|
||||||
|
.`).concat(Qu," .").concat(Qu,` {
|
||||||
|
margin-right: 0 `).concat(o,`;
|
||||||
|
}
|
||||||
|
|
||||||
|
body[`).concat(li,`] {
|
||||||
|
`).concat($k,": ").concat(m,`px;
|
||||||
|
}
|
||||||
|
`)},f0=function(){var t=parseInt(document.body.getAttribute(li)||"0",10);return isFinite(t)?t:0},o9=function(){f.useEffect(function(){return document.body.setAttribute(li,(f0()+1).toString()),function(){var t=f0()-1;t<=0?document.body.removeAttribute(li):document.body.setAttribute(li,t.toString())}},[])},i9=function(t){var n=t.noRelative,a=t.noImportant,o=t.gapMode,c=o===void 0?"margin":o;o9();var l=f.useMemo(function(){return r9(c)},[c]);return f.createElement(a9,{styles:s9(l,!n,c,a?"":"!important")})},vh=!1;if(typeof window<"u")try{var Ou=Object.defineProperty({},"passive",{get:function(){return vh=!0,!0}});window.addEventListener("test",Ou,Ou),window.removeEventListener("test",Ou,Ou)}catch{vh=!1}var Qo=vh?{passive:!1}:!1,l9=function(t){return t.tagName==="TEXTAREA"},sj=function(t,n){if(!(t instanceof Element))return!1;var a=window.getComputedStyle(t);return a[n]!=="hidden"&&!(a.overflowY===a.overflowX&&!l9(t)&&a[n]==="visible")},c9=function(t){return sj(t,"overflowY")},u9=function(t){return sj(t,"overflowX")},m0=function(t,n){var a=n.ownerDocument,o=n;do{typeof ShadowRoot<"u"&&o instanceof ShadowRoot&&(o=o.host);var c=oj(t,o);if(c){var l=ij(t,o),d=l[1],m=l[2];if(d>m)return!0}o=o.parentNode}while(o&&o!==a.body);return!1},d9=function(t){var n=t.scrollTop,a=t.scrollHeight,o=t.clientHeight;return[n,a,o]},f9=function(t){var n=t.scrollLeft,a=t.scrollWidth,o=t.clientWidth;return[n,a,o]},oj=function(t,n){return t==="v"?c9(n):u9(n)},ij=function(t,n){return t==="v"?d9(n):f9(n)},m9=function(t,n){return t==="h"&&n==="rtl"?-1:1},p9=function(t,n,a,o,c){var l=m9(t,window.getComputedStyle(n).direction),d=l*o,m=a.target,p=n.contains(m),g=!1,b=d>0,v=0,w=0;do{if(!m)break;var y=ij(t,m),C=y[0],S=y[1],N=y[2],E=S-N-l*C;(C||E)&&oj(t,m)&&(v+=E,w+=C);var T=m.parentNode;m=T&&T.nodeType===Node.DOCUMENT_FRAGMENT_NODE?T.host:T}while(!p&&m!==document.body||p&&(n.contains(m)||n===m));return(b&&Math.abs(v)<1||!b&&Math.abs(w)<1)&&(g=!0),g},Lu=function(t){return"changedTouches"in t?[t.changedTouches[0].clientX,t.changedTouches[0].clientY]:[0,0]},p0=function(t){return[t.deltaX,t.deltaY]},h0=function(t){return t&&"current"in t?t.current:t},h9=function(t,n){return t[0]===n[0]&&t[1]===n[1]},g9=function(t){return`
|
||||||
|
.block-interactivity-`.concat(t,` {pointer-events: none;}
|
||||||
|
.allow-interactivity-`).concat(t,` {pointer-events: all;}
|
||||||
|
`)},x9=0,Jo=[];function b9(t){var n=f.useRef([]),a=f.useRef([0,0]),o=f.useRef(),c=f.useState(x9++)[0],l=f.useState(aj)[0],d=f.useRef(t);f.useEffect(function(){d.current=t},[t]),f.useEffect(function(){if(t.inert){document.body.classList.add("block-interactivity-".concat(c));var S=Uk([t.lockRef.current],(t.shards||[]).map(h0),!0).filter(Boolean);return S.forEach(function(N){return N.classList.add("allow-interactivity-".concat(c))}),function(){document.body.classList.remove("block-interactivity-".concat(c)),S.forEach(function(N){return N.classList.remove("allow-interactivity-".concat(c))})}}},[t.inert,t.lockRef.current,t.shards]);var m=f.useCallback(function(S,N){if("touches"in S&&S.touches.length===2||S.type==="wheel"&&S.ctrlKey)return!d.current.allowPinchZoom;var E=Lu(S),T=a.current,M="deltaX"in S?S.deltaX:T[0]-E[0],k="deltaY"in S?S.deltaY:T[1]-E[1],_,R=S.target,L=Math.abs(M)>Math.abs(k)?"h":"v";if("touches"in S&&L==="h"&&R.type==="range")return!1;var ne=window.getSelection(),X=ne&&ne.anchorNode,I=X?X===R||X.contains(R):!1;if(I)return!1;var H=m0(L,R);if(!H)return!0;if(H?_=L:(_=L==="v"?"h":"v",H=m0(L,R)),!H)return!1;if(!o.current&&"changedTouches"in S&&(M||k)&&(o.current=_),!_)return!0;var q=o.current||_;return p9(q,N,S,q==="h"?M:k)},[]),p=f.useCallback(function(S){var N=S;if(!(!Jo.length||Jo[Jo.length-1]!==l)){var E="deltaY"in N?p0(N):Lu(N),T=n.current.filter(function(_){return _.name===N.type&&(_.target===N.target||N.target===_.shadowParent)&&h9(_.delta,E)})[0];if(T&&T.should){N.cancelable&&N.preventDefault();return}if(!T){var M=(d.current.shards||[]).map(h0).filter(Boolean).filter(function(_){return _.contains(N.target)}),k=M.length>0?m(N,M[0]):!d.current.noIsolation;k&&N.cancelable&&N.preventDefault()}}},[]),g=f.useCallback(function(S,N,E,T){var M={name:S,delta:N,target:E,should:T,shadowParent:v9(E)};n.current.push(M),setTimeout(function(){n.current=n.current.filter(function(k){return k!==M})},1)},[]),b=f.useCallback(function(S){a.current=Lu(S),o.current=void 0},[]),v=f.useCallback(function(S){g(S.type,p0(S),S.target,m(S,t.lockRef.current))},[]),w=f.useCallback(function(S){g(S.type,Lu(S),S.target,m(S,t.lockRef.current))},[]);f.useEffect(function(){return Jo.push(l),t.setCallbacks({onScrollCapture:v,onWheelCapture:v,onTouchMoveCapture:w}),document.addEventListener("wheel",p,Qo),document.addEventListener("touchmove",p,Qo),document.addEventListener("touchstart",b,Qo),function(){Jo=Jo.filter(function(S){return S!==l}),document.removeEventListener("wheel",p,Qo),document.removeEventListener("touchmove",p,Qo),document.removeEventListener("touchstart",b,Qo)}},[]);var y=t.removeScrollBar,C=t.inert;return f.createElement(f.Fragment,null,C?f.createElement(l,{styles:g9(c)}):null,y?f.createElement(i9,{noRelative:t.noRelative,gapMode:t.gapMode}):null)}function v9(t){for(var n=null;t!==null;)t instanceof ShadowRoot&&(n=t.host,t=t.host),t=t.parentNode;return n}const y9=Kk(rj,b9);var ac=f.forwardRef(function(t,n){return f.createElement(Hd,Ur({},t,{ref:n,sideCar:y9}))});ac.classNames=Hd.classNames;var w9=Object.defineProperty,ot=(t,n)=>w9(t,"name",{value:n,configurable:!0}),yh=["Enter"," "],j9=["ArrowDown","PageUp","Home"],lj=["ArrowUp","PageDown","End"],C9=[...j9,...lj],S9={ltr:[...yh,"ArrowRight"],rtl:[...yh,"ArrowLeft"]},N9={ltr:["ArrowLeft"],rtl:["ArrowRight"]},Gd="Menu",[Hl,E9,k9]=Td(Gd),[io,cj]=bn(Gd,[k9,Cs,Bd]),Rg=Cs(),uj=Bd(),[T9,lo]=io(Gd),[M9,sc]=io(Gd),R9=ot(t=>{const{__scopeMenu:n,open:a=!1,children:o,dir:c,onOpenChange:l,modal:d=!0}=t,m=Rg(n),[p,g]=f.useState(null),b=f.useRef(!1),v=Vt(l),w=Si(c);return f.useEffect(()=>{const y=ot(()=>{b.current=!0,document.addEventListener("pointerdown",C,{capture:!0,once:!0}),document.addEventListener("pointermove",C,{capture:!0,once:!0})},"handleKeyDown"),C=ot(()=>b.current=!1,"handlePointer");return document.addEventListener("keydown",y,{capture:!0}),()=>{document.removeEventListener("keydown",y,{capture:!0}),document.removeEventListener("pointerdown",C,{capture:!0}),document.removeEventListener("pointermove",C,{capture:!0})}},[]),f.useEffect(()=>{if(!a)return;const y=ot(()=>v(!1),"handleBlur");return window.addEventListener("blur",y),()=>window.removeEventListener("blur",y)},[a,v]),s.jsx(zd,{...m,children:s.jsx(T9,{scope:n,open:a,onOpenChange:v,content:p,onContentChange:g,children:s.jsx(M9,{scope:n,onClose:f.useCallback(()=>v(!1),[v]),isUsingKeyboardRef:b,dir:w,modal:d,children:o})})})},"Menu"),dj=f.forwardRef(ot(function(n,a){const{__scopeMenu:o,...c}=n,l=Rg(o);return s.jsx(Pd,{...l,...c,ref:a})},"MenuAnchor")),fj="MenuPortal",[A9,mj]=io(fj,{forceMount:void 0}),_9=ot(t=>{const{__scopeMenu:n,forceMount:a,children:o,container:c}=t,l=lo(fj,n);return s.jsx(A9,{scope:n,forceMount:a,children:s.jsx(fn,{present:a||l.open,children:s.jsx(rc,{asChild:!0,container:c,children:o})})})},"MenuPortal"),Nr="MenuContent",[D9,Ag]=io(Nr),O9=f.forwardRef(ot(function(n,a){const o=mj(Nr,n.__scopeMenu),{forceMount:c=o.forceMount,...l}=n,d=lo(Nr,n.__scopeMenu),m=sc(Nr,n.__scopeMenu);return s.jsx(
Line truncated
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const MT=t=>t.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase(),Dj=(...t)=>t.filter((n,a,o)=>!!n&&n.trim()!==""&&o.indexOf(n)===a).join(" ").trim();/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/var RT={xmlns:"http://www.w3.org/2000/svg",width:24,height:24,viewBox:"0 0 24 24",fill:"none",stroke:"currentColor",strokeWidth:2,strokeLinecap:"round",strokeLinejoin:"round"};/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const AT=f.forwardRef(({color:t="currentColor",size:n=24,strokeWidth:a=2,absoluteStrokeWidth:o,className:c="",children:l,iconNode:d,...m},p)=>f.createElement("svg",{ref:p,...RT,width:n,height:n,stroke:t,strokeWidth:o?Number(a)*24/Number(n):a,className:Dj("lucide",c),...m},[...d.map(([g,b])=>f.createElement(g,b)),...Array.isArray(l)?l:[l]]));/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const Ra=(t,n)=>{const a=f.forwardRef(({className:o,...c},l)=>f.createElement(AT,{ref:l,iconNode:n,className:Dj(`lucide-${MT(t)}`,o),...c}));return a.displayName=`${t}`,a};/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const _T=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],Oj=Ra("Check",_T);/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const DT=[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]],Lj=Ra("ChevronDown",DT);/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const OT=[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]],LT=Ra("ChevronRight",OT);/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const zT=[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]],PT=Ra("ChevronUp",zT);/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const IT=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],x0=Ra("CircleCheck",IT);/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const UT=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]],BT=Ra("Circle",UT);/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const $T=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M9 3v18",key:"fh3hqa"}]],HT=Ra("PanelLeft",$T);/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const GT=[["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}],["path",{d:"m21 21-4.3-4.3",key:"1qie3q"}]],VT=Ra("Search",GT);/**
|
||||||
|
* @license lucide-react v0.475.0 - ISC
|
||||||
|
*
|
||||||
|
* This source code is licensed under the ISC license.
|
||||||
|
* See the LICENSE file in the root directory of this source tree.
|
||||||
|
*/const FT=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],zj=Ra("X",FT),zg=ET,Pg=kT,YT=f.forwardRef(({className:t,inset:n,children:a,...o},c)=>s.jsxs(Aj,{ref:c,className:pe("flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",n&&"pl-8",t),...o,children:[a,s.jsx(LT,{className:"ml-auto"})]}));YT.displayName=Aj.displayName;const qT=f.forwardRef(({className:t,...n},a)=>s.jsx(_j,{ref:a,className:pe("z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",t),...n}));qT.displayName=_j.displayName;const Fd=f.forwardRef(({className:t,sideOffset:n=4,...a},o)=>s.jsx(TT,{children:s.jsx(Sj,{ref:o,sideOffset:n,className:pe("z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md","data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",t),...a})}));Fd.displayName=Sj.displayName;const Vl=f.forwardRef(({className:t,inset:n,...a},o)=>s.jsx(Ej,{ref:o,className:pe("relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&>svg]:size-4 [&>svg]:shrink-0",n&&"pl-8",t),...a}));Vl.displayName=Ej.displayName;const KT=f.forwardRef(({className:t,children:n,checked:a,...o},c)=>s.jsxs(kj,{ref:c,className:pe("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",t),checked:a,...o,children:[s.jsx("span",{className:"absolute left-2 flex h-3.5 w-3.5 items-center justify-center",children:s.jsx(Mj,{children:s.jsx(Oj,{className:"h-4 w-4"})})}),n]}));KT.displayName=kj.displayName;const WT=f.forwardRef(({className:t,children:n,...a},o)=>s.jsxs(Tj,{ref:o,className:pe("relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",t),...a,children:[s.jsx("span",{className:"absolute left-2 flex h-3.5 w-3.5 items-center justify-center",children:s.jsx(Mj,{children:s.jsx(BT,{className:"h-2 w-2 fill-current"})})}),n]}));WT.displayName=Tj.displayName;const Ig=f.forwardRef(({className:t,inset:n,...a},o)=>s.jsx(Nj,{ref:o,className:pe("px-2 py-1.5 text-sm font-semibold",n&&"pl-8",t),...a}));Ig.displayName=Nj.displayName;const Fl=f.forwardRef(({className:t,...n},a)=>s.jsx(Rj,{ref:a,className:pe("-mx-1 my-1 h-px bg-muted",t),...n}));Fl.displayName=Rj.displayName;const zp=768;function XT(){const[t,n]=f.useState(void 0);return f.useEffect(()=>{const a=window.matchMedia(`(max-width: ${zp-1}px)`),o=()=>{n(window.innerWidth<zp)};return a.addEventListener("change",o),n(window.innerWidth<zp),()=>a.removeEventListener("change",o)},[]),!!t}const _e=f.forwardRef(({className:t,type:n,...a},o)=>s.jsx("input",{type:n,className:pe("flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",t),ref:o,...a}));_e.displayName="Input";var ZT=Object.defineProperty,Pj=(t,n)=>ZT(t,"name",{value:n,configurable:!0}),b0="horizontal",QT=["horizontal","vertical"],JT=f.forwardRef(Pj(function(n,a){const{decorative:o,orientation:c=b0,...l}=n,d=Ij(c)?c:b0,p=o?{role:"none"}:{"aria-orientation":d==="vertical"?d:void 0,role:"separator"};return s.jsx(Me.div,{"data-orientation":d,...p,...l,ref:a})},"Separator"));function Ij(t){return QT.includes(t)}Pj(Ij,"isValidOrientation");var Uj=JT;const Bj=f.forwardRef(({className:t,orientation:n="horizontal",decorative:a=!0,...o},c)=>s.jsx(Uj,{ref:c,decorative:a,orientation:n,className:pe(Line truncated
|
||||||
|
--num-groups: ${l.groups.length};
|
||||||
|
--indicator-opacity: ${l.searchMode?0:1};
|
||||||
|
--num-skintones: ${z0};`}),d(()=>{l.customEmoji&&l.database&&L()}),d(()=>{l.customEmoji&&l.customEmoji.length?l.groups!==Fh&&(l.groups=Fh):l.groups!==Hp&&(l.currentGroupIndex&&l.currentGroupIndex--,l.groups=Hp)}),d(()=>{async function $(){l.databaseLoaded&&(l.currentSkinTone=await l.database.getPreferredSkinTone())}$()}),d(()=>{l.skinTones=Array(z0).fill().map(($,ie)=>ID(l.skinToneEmoji,ie))}),d(()=>{l.skinToneButtonText=l.skinTones[l.currentSkinTone]}),d(()=>{l.skinToneButtonLabel=l.i18n.skinToneLabel.replace("{skinTone}",l.i18n.skinTones[l.currentSkinTone])}),d(()=>{async function $(){const{database:ie}=l,ye=(await Promise.all(TD.map(Se=>ie.getEmojiByUnicodeOrName(Se)))).filter(Boolean);l.defaultFavoriteEmojis=ye}l.databaseLoaded&&$()});function L(){const{customEmoji:$,database:ie}=l,ye=$||Kp;ie.customEmoji!==ye&&(ie.customEmoji=ye)}d(()=>{async function $(){L();const{database:ie,defaultFavoriteEmojis:ye,numColumns:Se}=l,Be=await ie.getTopFavoriteEmoji(Se),Ke=await de(XS([...Be,...ye],We=>We.unicode||We.name).slice(0,Se));l.currentFavorites=Ke}l.databaseLoaded&&l.defaultFavoriteEmojis&&$()});function ne($){$D($,c,()=>{{const ie=getComputedStyle(a.rootElement),ye=parseInt(ie.getPropertyValue("--num-columns"),10),Se=ie.getPropertyValue("direction")==="rtl";l.numColumns=ye,l.isRtl=Se}})}function X($){iO($,c,ie=>{for(const{target:ye,isIntersecting:Se}of ie)ye.classList.toggle("onscreen",Se)})}d(()=>{async function $(){const{searchText:ie,currentGroup:ye,databaseLoaded:Se,customEmoji:Be}=l;if(!Se)l.currentEmojis=[],l.searchMode=!1;else if(ie.length>=CD){const Ke=await P(ie);l.searchText===ie&&(y(Ke),C(!0))}else{const{id:Ke}=ye;if(Ke!==-1||Be&&Be.length){const We=await se(Ke);l.currentGroup.id===Ke&&(y(We),C(!1))}}}$()});const I=()=>{ed(()=>FD(a.tabpanelElement))};d(()=>{const{currentEmojis:$,emojiVersion:ie}=l,ye=$.filter(Se=>Se.unicode).filter(Se=>P0(Se)&&!Yh.has(Se.unicode));if(!ie&&ye.length)y($),ed(()=>H(ye));else{const Se=ie?$:$.filter(q);y(Se),I()}});function H($){GD($,a.baselineEmoji,g)?I():l.currentEmojis=[...l.currentEmojis]}function q($){return!$.unicode||!P0($)||Yh.get($.unicode)}async function ce($){const ie=l.emojiVersion||await Vp();return $.filter(({version:ye})=>!ye||ye<=ie)}async function de($){return UD($,l.emojiVersion||await Vp())}async function se($){const ie=$===-1?l.customEmoji:await l.database.getEmojiByGroup($);return de(await ce(ie))}async function P($){return de(await ce(await l.database.getEmojiBySearchQuery($)))}d(()=>{}),d(()=>{function $(){const{searchMode:ye,currentEmojis:Se}=l;if(ye)return[{category:"",emojis:Se}];const Be=new Map;for(const Ke of Se){const We=Ke.category||"";let qt=Be.get(We);qt||(qt=[],Be.set(We,qt)),qt.push(Ke)}return[...Be.entries()].map(([Ke,We])=>({category:Ke,emojis:We})).sort((Ke,We)=>l.customCategorySorting(Ke.category,We.category))}const ie=$();S(ie)}),d(()=>{l.activeSearchItemId=l.activeSearchItem!==-1&&l.currentEmojis[l.activeSearchItem].id}),d(()=>{const{rawSearchText:$}=l;KS(()=>{l.searchText=($||"").trim(),l.activeSearchItem=-1})});function W($){if(!l.searchMode||!l.currentEmojis.length)return;const ie=ye=>{Ir($),l.activeSearchItem=Fp(ye,l.activeSearchItem,l.currentEmojis)};switch($.key){case"ArrowDown":return ie(!1);case"ArrowUp":return ie(!0);case"Enter":if(l.activeSearchItem===-1)l.activeSearchItem=0;else return Ir($),O(l.currentEmojis[l.activeSearchItem].id)}}function oe($){const{target:ie}=$,ye=ie.closest(".nav-button");if(!ye)return;const Se=parseInt(ye.dataset.groupId,10);a.searchElement.value="",l.rawSearchText="",l.searchText="",l.activeSearchItem=-1,l.currentGroupIndex=l.groups.findIndex(Be=>Be.id===Se)}function ae($){const{target:ie,key:ye}=$,Se=Be=>{Be&&(Ir($),Be.focus())};switch(ye){case"ArrowLeft":return Se(ie.previousElementSibling);case"ArrowRight":return Se(ie.nextElementSibling);case"Home":return Se(ie.parentElement.firstElementChild);case"End":return Se(ie.parentElement.lastElementChild)}}async function G($){const ie=await l.database.getEmojiByUnicodeOrName($),ye=[...l.currentEmojis,...l.currentFavorites].find(Be=>Be.id===$),Se=ye.unicode&&N(ye,l.currentSkinTone);return await l.database.incrementFavoriteEmojiCount($),{emoji:ie,skinTone:l.currentSkinTone,...Se&&{unicode:Se},...ye.name&&{name:ye.name}}}async function O($){const ie=G($);b("emoji-click-sync",ie),b("emoji-click",await ie)}function D($){const{target:ie}=$;if(!ie.classList.contains("emoji"))return;Ir($);const ye=ie.id.substring(4);O(ye)}function B($){l.currentSkinTone=$,l.skinTonePickerExpanded=!1,p("skintone-button"),b("skin-tone-change",{skinTone:$}),l.database.setPreferredSkinTone($)}function V($){const{target:{id:ie}}=$,ye=ie&&ie.match(/^skintone-(\d)/);if(!ye)return;Ir($);const Se=parseInt(ye[1],10);B(Se)}function Z($){l.skinTonePickerExpanded=!l.skinTonePickerExpanded,l.activeSkinTone=l.currentSkinTone,l.skinTonePickerExpanded&&(Ir($),ed(()=>p("skintone-list")))}d(()=>{l.skinTonePickerExpanded?a.skinToneDropdown.addEventListener("transitionend",()=>{l.skin
Line truncated
|
||||||
|
`);function Hu(t){return t.label!==void 0}var IO=3,UO="32px",BO="16px",q0=4e3,$O=356,HO=14,GO=20,VO=200;function jr(...t){return t.filter(Boolean).join(" ")}function FO(t){let[n,a]=t.split("-"),o=[];return n&&o.push(n),a&&o.push(a),o}var YO=t=>{var n,a,o,c,l,d,m,p,g,b,v;let{invert:w,toast:y,unstyled:C,interacting:S,setHeights:N,visibleToasts:E,heights:T,index:M,toasts:k,expanded:_,removeToast:R,defaultRichColors:L,closeButton:ne,style:X,cancelButtonStyle:I,actionButtonStyle:H,className:q="",descriptionClassName:ce="",duration:de,position:se,gap:P,loadingIcon:W,expandByDefault:oe,classNames:ae,icons:G,closeButtonAriaLabel:O="Close toast",pauseWhenPageIsHidden:D}=t,[B,V]=ve.useState(null),[Z,be]=ve.useState(null),[K,ge]=ve.useState(!1),[me,$]=ve.useState(!1),[ie,ye]=ve.useState(!1),[Se,Be]=ve.useState(!1),[Ke,We]=ve.useState(!1),[qt,vn]=ve.useState(0),[Xn,br]=ve.useState(0),yn=ve.useRef(y.duration||de||q0),wn=ve.useRef(null),Zn=ve.useRef(null),Kt=M===0,po=M+1<=E,Pt=y.type,Qn=y.dismissible!==!1,Da=y.className||"",Es=y.descriptionClassName||"",qr=ve.useMemo(()=>T.findIndex(De=>De.toastId===y.id)||0,[T,y.id]),ho=ve.useMemo(()=>{var De;return(De=y.closeButton)!=null?De:ne},[y.closeButton,ne]),Kr=ve.useMemo(()=>y.duration||de||q0,[y.duration,de]),Dn=ve.useRef(0),yt=ve.useRef(0),On=ve.useRef(0),Rt=ve.useRef(null),[Ai,_i]=se.split("-"),go=ve.useMemo(()=>T.reduce((De,rt,lt)=>lt>=qr?De:De+rt.height,0),[T,qr]),Wr=RO(),Oa=y.invert||w,Ar=Pt==="loading";yt.current=ve.useMemo(()=>qr*P+go,[qr,go]),ve.useEffect(()=>{yn.current=Kr},[Kr]),ve.useEffect(()=>{ge(!0)},[]),ve.useEffect(()=>{let De=Zn.current;if(De){let rt=De.getBoundingClientRect().height;return br(rt),N(lt=>[{toastId:y.id,height:rt,position:y.position},...lt]),()=>N(lt=>lt.filter(rn=>rn.toastId!==y.id))}},[N,y.id]),ve.useLayoutEffect(()=>{if(!K)return;let De=Zn.current,rt=De.style.height;De.style.height="auto";let lt=De.getBoundingClientRect().height;De.style.height=rt,br(lt),N(rn=>rn.find(It=>It.toastId===y.id)?rn.map(It=>It.toastId===y.id?{...It,height:lt}:It):[{toastId:y.id,height:lt,position:y.position},...rn])},[K,y.title,y.description,N,y.id]);let Wt=ve.useCallback(()=>{$(!0),vn(yt.current),N(De=>De.filter(rt=>rt.toastId!==y.id)),setTimeout(()=>{R(y)},VO)},[y,R,N,yt]);ve.useEffect(()=>{if(y.promise&&Pt==="loading"||y.duration===1/0||y.type==="loading")return;let De;return _||S||D&&Wr?(()=>{if(On.current<Dn.current){let rt=new Date().getTime()-Dn.current;yn.current=yn.current-rt}On.current=new Date().getTime()})():yn.current!==1/0&&(Dn.current=new Date().getTime(),De=setTimeout(()=>{var rt;(rt=y.onAutoClose)==null||rt.call(y,y),Wt()},yn.current)),()=>clearTimeout(De)},[_,S,y,Pt,D,Wr,Wt]),ve.useEffect(()=>{y.delete&&Wt()},[Wt,y.delete]);function La(){var De,rt,lt;return G!=null&&G.loading?ve.createElement("div",{className:jr(ae==null?void 0:ae.loader,(De=y==null?void 0:y.classNames)==null?void 0:De.loader,"sonner-loader"),"data-visible":Pt==="loading"},G.loading):W?ve.createElement("div",{className:jr(ae==null?void 0:ae.loader,(rt=y==null?void 0:y.classNames)==null?void 0:rt.loader,"sonner-loader"),"data-visible":Pt==="loading"},W):ve.createElement(SO,{className:jr(ae==null?void 0:ae.loader,(lt=y==null?void 0:y.classNames)==null?void 0:lt.loader),visible:Pt==="loading"})}return ve.createElement("li",{tabIndex:0,ref:Zn,className:jr(q,Da,ae==null?void 0:ae.toast,(n=y==null?void 0:y.classNames)==null?void 0:n.toast,ae==null?void 0:ae.default,ae==null?void 0:ae[Pt],(a=y==null?void 0:y.classNames)==null?void 0:a[Pt]),"data-sonner-toast":"","data-rich-colors":(o=y.richColors)!=null?o:L,"data-styled":!(y.jsx||y.unstyled||C),"data-mounted":K,"data-promise":!!y.promise,"data-swiped":Ke,"data-removed":me,"data-visible":po,"data-y-position":Ai,"data-x-position":_i,"data-index":M,"data-front":Kt,"data-swiping":ie,"data-dismissible":Qn,"data-type":Pt,"data-invert":Oa,"data-swipe-out":Se,"data-swipe-direction":Z,"data-expanded":!!(_||oe&&K),style:{"--index":M,"--toasts-before":M,"--z-index":k.length-M,"--offset":`${me?qt:yt.current}px`,"--initial-height":oe?"auto":`${Xn}px`,...X,...y.style},onDragEnd:()=>{ye(!1),V(null),Rt.current=null},onPointerDown:De=>{Ar||!Qn||(wn.current=new Date,vn(yt.current),De.target.setPointerCapture(De.pointerId),De.target.tagName!=="BUTTON"&&(ye(!0),Rt.current={x:De.clientX,y:De.clientY}))},onPointerUp:()=>{var De,rt,lt,rn;if(Se||!Qn)return;Rt.current=null;let It=Number(((De=Zn.current)==null?void 0:De.style.getPropertyValue("--swipe-amount-x").replace("px",""))||0),Jn=Number(((rt=Zn.current)==null?void 0:rt.style.getPropertyValue("--swipe-amount-y").replace("px",""))||0),er=new Date().getTime()-((lt=wn.current)==null?void 0:lt.getTime()),bt=B==="x"?It:Jn,Ln=Math.abs(bt)/er;if(Math.abs(bt)>=GO||Ln>.11){vn(yt.current),(rn=y.onDismiss)==null||rn.call(y,y),be(B==="x"?It>0?"right":"left":Jn>0?"down":"up"),Wt(),Be(!0),We(!1);return}ye(!1),V(null)},onPointerMove:De=>{var rt,lt,rn,It;if(!Rt.current||!Qn||((rt=window.getSelection())==null?void 0:rt.toS
Line truncated
|
||||||
|
In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function X0(t,n){var a=Object.keys(t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);n&&(o=o.filter(function(c){return Object.getOwnPropertyDescriptor(t,c).enumerable})),a.push.apply(a,o)}return a}function Z0(t){for(var n=1;n<arguments.length;n++){var a=arguments[n]!=null?arguments[n]:{};n%2?X0(Object(a),!0).forEach(function(o){JO(t,o,a[o])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(a)):X0(Object(a)).forEach(function(o){Object.defineProperty(t,o,Object.getOwnPropertyDescriptor(a,o))})}return t}function nL(t,n){if(t==null)return{};var a,o,c=rL(t,n);if(Object.getOwnPropertySymbols){var l=Object.getOwnPropertySymbols(t);for(o=0;o<l.length;o++)a=l[o],n.indexOf(a)===-1&&{}.propertyIsEnumerable.call(t,a)&&(c[a]=t[a])}return c}function rL(t,n){if(t==null)return{};var a={};for(var o in t)if({}.hasOwnProperty.call(t,o)){if(n.indexOf(o)!==-1)continue;a[o]=t[o]}return a}function aL(t,n){return QO(t)||eL(t,n)||iL(t,n)||tL()}function sL(t,n){if(typeof t!="object"||!t)return t;var a=t[Symbol.toPrimitive];if(a!==void 0){var o=a.call(t,n);if(typeof o!="object")return o;throw new TypeError("@@toPrimitive must return a primitive value.")}return(n==="string"?String:Number)(t)}function oL(t){var n=sL(t,"string");return typeof n=="symbol"?n:n+""}function iL(t,n){if(t){if(typeof t=="string")return W0(t,n);var a={}.toString.call(t).slice(8,-1);return a==="Object"&&t.constructor&&(a=t.constructor.name),a==="Map"||a==="Set"?Array.from(t):a==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(a)?W0(t,n):void 0}}function lL(t,n,a){return n in t?Object.defineProperty(t,n,{value:a,enumerable:!0,configurable:!0,writable:!0}):t[n]=a,t}function Q0(t,n){var a=Object.keys(t);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(t);n&&(o=o.filter(function(c){return Object.getOwnPropertyDescriptor(t,c).enumerable})),a.push.apply(a,o)}return a}function J0(t){for(var n=1;n<arguments.length;n++){var a=arguments[n]!=null?arguments[n]:{};n%2?Q0(Object(a),!0).forEach(function(o){lL(t,o,a[o])}):Object.getOwnPropertyDescriptors?Object.defineProperties(t,Object.getOwnPropertyDescriptors(a)):Q0(Object(a)).forEach(function(o){Object.defineProperty(t,o,Object.getOwnPropertyDescriptor(a,o))})}return t}function cL(){for(var t=arguments.length,n=new Array(t),a=0;a<t;a++)n[a]=arguments[a];return function(o){return n.reduceRight(function(c,l){return l(c)},o)}}function Al(t){return function n(){for(var a=this,o=arguments.length,c=new Array(o),l=0;l<o;l++)c[l]=arguments[l];return c.length>=t.length?t.apply(this,c):function(){for(var d=arguments.length,m=new Array(d),p=0;p<d;p++)m[p]=arguments[p];return n.apply(a,[].concat(c,m))}}}function hd(t){return{}.toString.call(t).includes("Object")}function uL(t){return!Object.keys(t).length}function Ql(t){return typeof t=="function"}function dL(t,n){return Object.prototype.hasOwnProperty.call(t,n)}function fL(t,n){return hd(n)||us("changeType"),Object.keys(n).some(function(a){return!dL(t,a)})&&us("changeField"),n}function mL(t){Ql(t)||us("selectorType")}function pL(t){Ql(t)||hd(t)||us("handlerType"),hd(t)&&Object.values(t).some(function(n){return!Ql(n)})&&us("handlersType")}function hL(t){t||us("initialIsRequired"),hd(t)||us("initialType"),uL(t)&&us("initialContent")}function gL(t,n){throw new Error(t[n]||t.default)}var xL={initialIsRequired:"initial state is required",initialType:"initial state should be an object",initialContent:"initial state shouldn't be an empty object",handlerType:"handler should be an object or a function",handlersType:"all handlers should be a functions",selectorType:"selector should be a function",changeType:"provided value of changes should be an object",changeField:'it seams you want to change a field in the state which is not specified in the "initial" state',default:"an unknown error accured in `state-local` package"},us=Al(gL)(xL),Gu={changes:fL,selector:mL,handler:pL,initial:hL};function bL(t){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};Gu.initial(t),Gu.handler(n);var a={current:t},o=Al(wL)(a,n),c=Al(yL)(a),l=Al(Gu.changes)(t),d=Al(vL)(a);function m(){var g=arguments.length>0&&arguments[0]!==void 0?arguments[0]:function(b){return b};return Gu.selector(g),g(a.current)}function p(g){cL(o,c,l,d)(g)}return[m,p]}function vL(t,n){return Ql(n)?n(t.current):n}function yL(t,n){return t.current=J0(J0({},t.current),n),n}function wL(t,n,a){return Ql(n)?n(t.current):Object.keys(a).forEach(function(o){var c;return(c=n[o])===null||c===void 0?void 0:c.call(n,t.current[o])}),a}var jL={create:bL},CL={paths:{vs:"https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs"}};function SL(t){return function n(){for(var a=this,o=arguments.length,c=new Array(o),l=0;l<o;l++)c[l]=arguments[l];return c.length>=t.length?t.apply(this,c):function(){for(var d=arguments.length,m=new Array(d),p=0;p<d;p++)m[p]=arguments[p];
Line truncated
|
||||||
|
You are using deprecated way of configuration.
|
||||||
|
|
||||||
|
Instead of using
|
||||||
|
monaco.config({ urls: { monacoBase: '...' } })
|
||||||
|
use
|
||||||
|
monaco.config({ paths: { vs: '...' } })
|
||||||
|
|
||||||
|
For more please check the link https://github.com/suren-atoyan/monaco-loader#config
|
||||||
|
`},e2=SL(TL)(t5),ML={config:EL},RL=function(){for(var n=arguments.length,a=new Array(n),o=0;o<n;o++)a[o]=arguments[o];return function(c){return a.reduceRight(function(l,d){return d(l)},c)}};function n5(t,n){return Object.keys(n).forEach(function(a){n[a]instanceof Object&&t[a]&&Object.assign(n[a],n5(t[a],n[a]))}),Z0(Z0({},t),n)}var AL={type:"cancelation",msg:"operation is manually canceled"};function Wp(t){var n=!1,a=new Promise(function(o,c){t.then(function(l){return n?c(AL):o(l)}),t.catch(c)});return a.cancel=function(){return n=!0},a}var _L=["monaco"],DL=jL.create({config:CL,isInitialized:!1,resolve:null,reject:null,monaco:null}),r5=aL(DL,2),pc=r5[0],nf=r5[1];function OL(t){var n=ML.config(t),a=n.monaco,o=nL(n,_L);nf(function(c){return{config:n5(c.config,o),monaco:a}})}function LL(){var t=pc(function(n){var a=n.monaco,o=n.isInitialized,c=n.resolve;return{monaco:a,isInitialized:o,resolve:c}});if(!t.isInitialized){if(nf({isInitialized:!0}),t.monaco)return t.resolve(t.monaco),Wp(Xp);if(window.monaco&&window.monaco.editor)return a5(window.monaco),t.resolve(window.monaco),Wp(Xp);RL(zL,IL)(UL)}return Wp(Xp)}function zL(t){return document.body.appendChild(t)}function PL(t){var n=document.createElement("script");return t&&(n.src=t),n}function IL(t){var n=pc(function(o){var c=o.config,l=o.reject;return{config:c,reject:l}}),a=PL("".concat(n.config.paths.vs,"/loader.js"));return a.onload=function(){return t()},a.onerror=n.reject,a}function UL(){var t=pc(function(a){var o=a.config,c=a.resolve,l=a.reject;return{config:o,resolve:c,reject:l}}),n=window.require;n.config(t.config),n(["vs/editor/editor.main"],function(a){var o=a.m||a;a5(o),t.resolve(o)},function(a){t.reject(a)})}function a5(t){pc().monaco||nf({monaco:t})}function BL(){return pc(function(t){var n=t.monaco;return n})}var Xp=new Promise(function(t,n){return nf({resolve:t,reject:n})}),s5={config:OL,init:LL,__getMonacoInstance:BL},$L={wrapper:{display:"flex",position:"relative",textAlign:"initial"},fullWidth:{width:"100%"},hide:{display:"none"}},Zp=$L,HL={container:{display:"flex",height:"100%",width:"100%",justifyContent:"center",alignItems:"center"}},GL=HL;function VL({children:t}){return ve.createElement("div",{style:GL.container},t)}var FL=VL,YL=FL;function qL({width:t,height:n,isEditorReady:a,loading:o,_ref:c,className:l,wrapperProps:d}){return ve.createElement("section",{style:{...Zp.wrapper,width:t,height:n},...d},!a&&ve.createElement(YL,null,o),ve.createElement("div",{ref:c,style:{...Zp.fullWidth,...!a&&Zp.hide},className:l}))}var KL=qL,o5=f.memo(KL);function WL(t){f.useEffect(t,[])}var i5=WL;function XL(t,n,a=!0){let o=f.useRef(!0);f.useEffect(o.current||!a?()=>{o.current=!1}:t,n)}var Yn=XL;function zl(){}function si(t,n,a,o){return ZL(t,o)||QL(t,n,a,o)}function ZL(t,n){return t.editor.getModel(l5(t,n))}function QL(t,n,a,o){return t.editor.createModel(n,a,o?l5(t,o):void 0)}function l5(t,n){return t.Uri.parse(n)}function JL({original:t,modified:n,language:a,originalLanguage:o,modifiedLanguage:c,originalModelPath:l,modifiedModelPath:d,keepCurrentOriginalModel:m=!1,keepCurrentModifiedModel:p=!1,theme:g="light",loading:b="Loading...",options:v={},height:w="100%",width:y="100%",className:C,wrapperProps:S={},beforeMount:N=zl,onMount:E=zl}){let[T,M]=f.useState(!1),[k,_]=f.useState(!0),R=f.useRef(null),L=f.useRef(null),ne=f.useRef(null),X=f.useRef(E),I=f.useRef(N),H=f.useRef(!1);i5(()=>{let se=s5.init();return se.then(P=>(L.current=P)&&_(!1)).catch(P=>(P==null?void 0:P.type)!=="cancelation"&&console.error("Monaco initialization: error:",P)),()=>R.current?de():se.cancel()}),Yn(()=>{if(R.current&&L.current){let se=R.current.getOriginalEditor(),P=si(L.current,t||"",o||a||"text",l||"");P!==se.getModel()&&se.setModel(P)}},[l],T),Yn(()=>{if(R.current&&L.current){let se=R.current.getModifiedEditor(),P=si(L.current,n||"",c||a||"text",d||"");P!==se.getModel()&&se.setModel(P)}},[d],T),Yn(()=>{let se=R.current.getModifiedEditor();se.getOption(L.current.editor.EditorOption.readOnly)?se.setValue(n||""):n!==se.getValue()&&(se.executeEdits("",[{range:se.getModel().getFullModelRange(),text:n||"",forceMoveMarkers:!0}]),se.pushUndoStop())},[n],T),Yn(()=>{var se,P;(P=(se=R.current)==null?void 0:se.getModel())==null||P.original.setValue(t||"")},[t],T),Yn(()=>{let{original:se,modified:P}=R.current.getModel();L.current.editor.setModelLanguage(se,o||a||"text"),L.current.editor.setModelLanguage(P,c||a||"text")},[a,o,c],T),Yn(()=>{var se;(se=L.current)==null||se.editor.setTheme(g)},[g],T),Yn(()=>{var se;(se=R.current)==null||se.updateOptions(v)},[v],T);let q=f.useCallback(()=>{var W;if(!L.current)return;I.current(L.current);let se=si(L.current,t||"",o||a||"text",l||""),P=si(L.current,n||"",c||a||"text",d||"");(W=R.current)==null||W.setModel({original:se,modified:P})},[a,n,c,t,o,l,d]),ce=f.useCallback(()=>{var se;!H.current&&ne.current&&(R.current=L.current.editor.createDiffEditor(ne.current,{automaticLayout:!0,...v}),q(),(se=L.current)==null||se.editor.setTheme(g),M(!0),H.current=!
Line truncated
|
||||||
|
987654321098765432`,className:"min-h-[120px] font-mono text-sm"})]}),s.jsx(mo,{result:a.owners}),s.jsx("div",{className:"mt-auto",children:s.jsx(ue,{type:"button",variant:"secondary",disabled:o||!t.ownerUserIds.trim(),onClick:c,children:o?"Checking with Discord…":"Verify owner IDs"})})]})}function Mz({onContinue:t}){return s.jsx("div",{className:"mx-auto flex w-full max-w-lg flex-1 flex-col justify-center py-8",children:s.jsxs("div",{className:"rounded-2xl border border-border bg-card px-6 py-10 text-center md:px-10",children:[s.jsx("div",{className:"mx-auto mb-6 flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary",children:s.jsx(xe,{icon:jd,size:40})}),s.jsx("h2",{className:"text-2xl font-semibold tracking-tight",children:"Setup complete"}),s.jsxs("p",{className:"mx-auto mt-3 max-w-sm text-sm leading-relaxed text-muted-foreground",children:["Your configuration has been saved. Restart the server for changes to take effect — stop the running process, then start it again (for example"," ",s.jsx("code",{className:"rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs",children:"npm start"}),")."]}),s.jsx(Ye,{className:"mt-6 text-left",children:s.jsx(qe,{children:"After restarting, sign in with Discord to open the dashboard."})}),s.jsx(ue,{type:"button",size:"lg",className:"mt-8 min-w-[220px]",onClick:t,children:"Continue to sign in"})]})})}function Rz({currentIndex:t,validating:n,saving:a,onBack:o,onNext:c,onFinish:l}){const d=t===ui.length-1;return s.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[s.jsxs(ue,{type:"button",variant:"ghost",disabled:t===0||a,onClick:o,children:[s.jsx(xe,{icon:T2,size:18,className:"mr-1"}),"Back"]}),s.jsx("div",{className:"flex flex-1 justify-end gap-2",children:d?s.jsx(ue,{type:"button",size:"lg",disabled:a||n,onClick:l,className:"min-w-[200px]",children:a?"Saving…":"Finish setup"}):s.jsxs(ue,{type:"button",size:"lg",disabled:n||a,onClick:c,children:["Continue",s.jsx(xe,{icon:lg,size:18,className:"ml-1"})]})})]})}function Az({values:t,setValues:n,validation:a,validating:o,onValidate:c}){return s.jsxs("div",{className:"flex flex-1 flex-col gap-5",children:[s.jsxs("p",{className:"text-sm leading-relaxed text-muted-foreground",children:["An Emerald Services API key is optional, but required to use the one-click addon installer in the dashboard. You can get yours from the"," ",s.jsx("a",{href:"https://emeraldsrv.dev",target:"_blank",rel:"noreferrer",className:"font-medium text-primary hover:underline",children:"Emerald Services Marketplace"}),"."]}),s.jsxs("div",{className:"space-y-2",children:[s.jsx(Ae,{htmlFor:"emeraldApiKey",children:"Emerald API key (Optional)"}),s.jsx(_e,{id:"emeraldApiKey",type:"password",autoComplete:"off",value:t.emeraldApiKey,onChange:l=>n({...t,emeraldApiKey:l.target.value}),placeholder:"em_...",className:"h-11 font-mono text-sm"})]}),s.jsx(mo,{result:a.emeraldApi}),s.jsx("div",{className:"mt-auto",children:s.jsx(ue,{type:"button",variant:"secondary",disabled:o,onClick:c,children:o?"Checking…":t.emeraldApiKey?"Verify key":"Skip step"})})]})}function _z({values:t,setValues:n,validation:a,validating:o,onValidate:c}){const l=a.database,d=t.dbDriver==="mysql";return s.jsxs("div",{className:"flex flex-1 flex-col gap-6",children:[s.jsxs("p",{className:"text-sm leading-relaxed text-muted-foreground",children:["Choose your database storage engine. Select ",s.jsx("strong",{children:"SQLite"})," for instant zero-config local storage, or ",s.jsx("strong",{children:"MySQL / MariaDB"})," for external host databases."]}),s.jsxs("div",{className:"grid gap-4 sm:grid-cols-2",children:[s.jsxs("div",{onClick:()=>n(m=>({...m,dbDriver:"sqlite"})),className:pe("cursor-pointer rounded-xl border p-4 transition-all hover:border-primary",t.dbDriver==="sqlite"?"border-primary bg-primary/5 ring-2 ring-primary/20":"border-border bg-card"),children:[s.jsxs("div",{className:"flex items-center justify-between mb-2",children:[s.jsx("span",{className:"font-semibold text-foreground",children:"SQLite"}),s.jsx("span",{className:"rounded-full bg-emerald-500/10 px-2.5 py-0.5 text-xs font-medium text-emerald-500",children:"Zero Config"})]}),s.jsxs("p",{className:"text-xs text-muted-foreground",children:["Single-file SQL database stored locally at ",s.jsx("code",{className:"font-mono",children:"./Data/supportbot.db"}),". Zero extra setup required."]})]}),s.jsxs("div",{onClick:()=>n(m=>({...m,dbDriver:"mysql"})),className:pe("cursor-pointer rounded-xl border p-4 transition-all hover:border-primary",t.dbDriver==="mysql"?"border-primary bg-primary/5 ring-2 ring-primary/20":"border-border bg-card"),children:[s.jsxs("div",{className:"flex items-center justify-between mb-2",children:[s.jsx("span",{className:"font-semibold text-foreground",children:"MySQL / MariaDB"}),s.jsx("span",{className:"rounded-full bg-blue-500/10 px-2.5 py-0.5 text-xs font-medium text-blue-500",children:"External Database"})]}),s.jsx("p",{className:"text-xs te
Line truncated
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
*,:before,:after{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }::backdrop{--tw-border-spacing-x: 0;--tw-border-spacing-y: 0;--tw-translate-x: 0;--tw-translate-y: 0;--tw-rotate: 0;--tw-skew-x: 0;--tw-skew-y: 0;--tw-scale-x: 1;--tw-scale-y: 1;--tw-pan-x: ;--tw-pan-y: ;--tw-pinch-zoom: ;--tw-scroll-snap-strictness: proximity;--tw-gradient-from-position: ;--tw-gradient-via-position: ;--tw-gradient-to-position: ;--tw-ordinal: ;--tw-slashed-zero: ;--tw-numeric-figure: ;--tw-numeric-spacing: ;--tw-numeric-fraction: ;--tw-ring-inset: ;--tw-ring-offset-width: 0px;--tw-ring-offset-color: #fff;--tw-ring-color: rgb(59 130 246 / .5);--tw-ring-offset-shadow: 0 0 #0000;--tw-ring-shadow: 0 0 #0000;--tw-shadow: 0 0 #0000;--tw-shadow-colored: 0 0 #0000;--tw-blur: ;--tw-brightness: ;--tw-contrast: ;--tw-grayscale: ;--tw-hue-rotate: ;--tw-invert: ;--tw-saturate: ;--tw-sepia: ;--tw-drop-shadow: ;--tw-backdrop-blur: ;--tw-backdrop-brightness: ;--tw-backdrop-contrast: ;--tw-backdrop-grayscale: ;--tw-backdrop-hue-rotate: ;--tw-backdrop-invert: ;--tw-backdrop-opacity: ;--tw-backdrop-saturate: ;--tw-backdrop-sepia: ;--tw-contain-size: ;--tw-contain-layout: ;--tw-contain-paint: ;--tw-contain-style: }*,:before,:after{box-sizing:border-box;border-width:0;border-style:solid;border-color:#e5e7eb}:before,:after{--tw-content: ""}html,:host{line-height:1.5;-webkit-text-size-adjust:100%;-moz-tab-size:4;-o-tab-size:4;tab-size:4;font-family:Poppins,system-ui,sans-serif;font-feature-settings:normal;font-variation-settings:normal;-webkit-tap-highlight-color:transparent}body{margin:0;line-height:inherit}hr{height:0;color:inherit;border-top-width:1px}abbr:where([title]){-webkit-text-decoration:underline dotted;text-decoration:underline dotted}h1,h2,h3,h4,h5,h6{font-size:inherit;font-weight:inherit}a{color:inherit;text-decoration:inherit}b,strong{font-weight:bolder}code,kbd,samp,pre{font-family:ui-monospace,SFMono-Regular,Menlo,Monaco,Consolas,Liberation Mono,Courier New,monospace;font-feature-settings:normal;font-variation-settings:normal;font-size:1em}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}table{text-indent:0;border-color:inherit;border-collapse:collapse}button,input,optgroup,select,textarea{font-family:inherit;font-feature-settings:inherit;font-variation-settings:inherit;font-size:100%;font-weight:inherit;line-height:inherit;letter-spacing:inherit;color:inherit;margin:0;padding:0}button,select{text-transform:none}button,input:where([type=button]),input:where([type=reset]),input:where([type=submit]){-webkit-appearance:button;background-color:transparent;background-image:none}:-moz-focusring{outline:auto}:-moz-ui-invalid{box-shadow:none}progress{vertical-align:baseline}::-webkit-inner-spin-button,::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}::-webkit-search-decoration{-webkit-appearance:none}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}summary{display:list-item}blockquote,dl,dd,h1,h2,h3,h4,h5,h6,hr,figure,p,pre{margin:0}fieldset{margin:0;padding:0}legend{padding:0}ol,ul,menu{list-style:none;margin:0;padding:0}dialog{padding:0}textarea{resize:vertical}input::-moz-placeholder,textarea::-moz-placeholder{opacity:1;color:#9ca3af}input::placeholder,textarea::placeholder{opacity:1;color:#9ca3af}button,[role=button]{cursor:pointer}:disabled{cursor:default}img,svg,video,canvas,audio,iframe,embed,object{display:block;vertical-align:middle}img,video{max-width:100%;height:auto}[hidden]:where(:not([hidden=until-found])){display:none}:root{--background: 240 10% 4%;--foreground: 0 0% 98%;--card: 240 7% 11%;--card-foreground: 0 0% 98%;--popover: 240 8% 10%;--popover-foreground: 0 0% 98%;--primary: 141 45% 49%;--primary-foreground: 0 0% 100%;--secondary: 240 6% 14%;--secondary-foreground: 0 0% 90%;--muted: 240 6% 14%;--muted-foreground: 240 5%Line truncated
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>SupportBot Dashboard</title>
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link
|
||||||
|
href="https://fonts.googleapis.com/css2?family=Poppins:ital,wght@0,400;0,500;0,600;0,700;1,400&display=swap"
|
||||||
|
rel="stylesheet"
|
||||||
|
/>
|
||||||
|
<script type="module" crossorigin src="/assets/index-CzmlDpil.js"></script>
|
||||||
|
<link rel="stylesheet" crossorigin href="/assets/index-Dx-VdUAO.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 81 KiB |
Reference in new issue
Block a user