Compare commits
27
Commits
v26.1-pre1
...
26.2-pre1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
|
||||
# Dashboard UI source at repo root only (do not ignore public/dashboard/)
|
||||
/dashboard/
|
||||
|
||||
# Local git hooks + secret-stripping scripts (run npm run git:setup)
|
||||
scripts/
|
||||
|
||||
# Local secrets — use *.example as templates
|
||||
Configs/api.yml
|
||||
+1332
-97
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,111 @@
|
||||
Command:
|
||||
Name: "applicationsetup"
|
||||
Description: "Posts the application panel"
|
||||
Permission:
|
||||
- "ManageGuild"
|
||||
|
||||
Application:
|
||||
ReviewChannelId: "1481380207305691287"
|
||||
CooldownHours: 24
|
||||
OneOpenApplicationPerUserPerType: true
|
||||
|
||||
Panel:
|
||||
Title: "Applications"
|
||||
Description: "Choose an application type from the dropdown below."
|
||||
Placeholder: "Select an application type"
|
||||
|
||||
Review:
|
||||
Title: "New Application"
|
||||
AcceptLabel: "Accept"
|
||||
RejectLabel: "Reject"
|
||||
|
||||
Applications:
|
||||
staff:
|
||||
Name: "Staff Team"
|
||||
Description: "Apply for the staff team"
|
||||
AcceptedRoleId: "" # Leave "" blank to disable
|
||||
Questions:
|
||||
Username:
|
||||
Label: "What is your Discord username?"
|
||||
Placeholder: "ExampleUser"
|
||||
Required: true
|
||||
Style: "Short"
|
||||
MinLength: 2
|
||||
MaxLength: 50
|
||||
|
||||
Age:
|
||||
Label: "How old are you?"
|
||||
Placeholder: "18"
|
||||
Required: true
|
||||
Style: "Short"
|
||||
MinLength: 1
|
||||
MaxLength: 3
|
||||
|
||||
Timezone:
|
||||
Label: "What is your timezone?"
|
||||
Placeholder: "GMT / BST / EST"
|
||||
Required: true
|
||||
Style: "Short"
|
||||
MinLength: 2
|
||||
MaxLength: 30
|
||||
|
||||
Experience:
|
||||
Label: "What experience do you have?"
|
||||
Placeholder: "Tell us about your experience"
|
||||
Required: true
|
||||
Style: "Paragraph"
|
||||
MinLength: 10
|
||||
MaxLength: 1000
|
||||
|
||||
Reason:
|
||||
Label: "Why should we accept you?"
|
||||
Placeholder: "Tell us why"
|
||||
Required: true
|
||||
Style: "Paragraph"
|
||||
MinLength: 10
|
||||
MaxLength: 1000
|
||||
|
||||
developer:
|
||||
Name: "Developer"
|
||||
Description: "Apply for the development team"
|
||||
AcceptedRoleId: ""
|
||||
Questions:
|
||||
Username:
|
||||
Label: "What is your Discord username?"
|
||||
Placeholder: "ExampleUser"
|
||||
Required: true
|
||||
Style: "Short"
|
||||
MinLength: 2
|
||||
MaxLength: 50
|
||||
|
||||
Languages:
|
||||
Label: "What languages do you know?"
|
||||
Placeholder: "JavaScript, Python, Lua"
|
||||
Required: true
|
||||
Style: "Paragraph"
|
||||
MinLength: 3
|
||||
MaxLength: 500
|
||||
|
||||
Projects:
|
||||
Label: "What projects have you worked on?"
|
||||
Placeholder: "Describe your work"
|
||||
Required: true
|
||||
Style: "Paragraph"
|
||||
MinLength: 10
|
||||
MaxLength: 1000
|
||||
|
||||
Availability:
|
||||
Label: "How active can you be?"
|
||||
Placeholder: "How much time can you commit?"
|
||||
Required: true
|
||||
Style: "Paragraph"
|
||||
MinLength: 5
|
||||
MaxLength: 500
|
||||
|
||||
Reason:
|
||||
Label: "Why do you want this role?"
|
||||
Placeholder: "Tell us why"
|
||||
Required: true
|
||||
Style: "Paragraph"
|
||||
MinLength: 10
|
||||
MaxLength: 1000
|
||||
@@ -0,0 +1,11 @@
|
||||
|
||||
# SupportBot | Emerald Services
|
||||
# Discord Stats addon | Created by Xiled Emerald
|
||||
|
||||
Guild_ID: "GUILD_ID_HERE"
|
||||
|
||||
StatsCategory: "Server Statistics"
|
||||
|
||||
UserCount_Name: "Total Members:"
|
||||
ChannelCount_Name: "Total Channels:"
|
||||
RoleCount_Name: "Total Roles:"
|
||||
@@ -0,0 +1,16 @@
|
||||
# Minecraft Server Configuration
|
||||
|
||||
Java:
|
||||
IP: "play.sapling.gg"
|
||||
Port: 25565
|
||||
DisplayName: "Java Edition"
|
||||
Bedrock:
|
||||
IP: "play.sapling.gg"
|
||||
Port: 19132
|
||||
DisplayName: "Bedrock Edition"
|
||||
|
||||
Embed:
|
||||
Color: "#57F287"
|
||||
OfflineColor: "#ED4245"
|
||||
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
# SupportBot Temporary Voice Channels Configuration
|
||||
# Addon Version 1.0
|
||||
|
||||
Enabled: true
|
||||
|
||||
# The ID of the voice channel that users join to create a new one
|
||||
JoinToCreateChannelID: "CHANNEL_ID"
|
||||
|
||||
# The category ID where new channels should be created (Optional)
|
||||
# If left blank, it will create it in the same category as the JoinToCreate channel
|
||||
CategoryID: "CATEGORY_ID"
|
||||
|
||||
# The name pattern for new channels
|
||||
|
||||
# Placeholders:
|
||||
# {user}: Username
|
||||
ChannelNamePattern: "🔊 {user}'s Channel"
|
||||
|
||||
# Should the channel be deleted when the last person leaves?
|
||||
AutoDelete: true
|
||||
|
||||
# Voice Channel Hub (Control Panel)
|
||||
Hub:
|
||||
Enabled: true
|
||||
SendOnCreate: true
|
||||
LayoutStyle: "classic" # classic | modern
|
||||
|
||||
# Classic Layout Settings (Standard Embed + Buttons)
|
||||
Classic:
|
||||
Title: "Voice Channel Hub"
|
||||
Description: "Manage your voice channel using the buttons below."
|
||||
Color: "#57F287"
|
||||
|
||||
# Modern Layout Settings (V2 Containers)
|
||||
Modern:
|
||||
Title: "Voice Channel Controller"
|
||||
Body: "Control temporary voice channel using this control panel."
|
||||
Color: "#57F287"
|
||||
@@ -0,0 +1,28 @@
|
||||
# +==================================================================================+
|
||||
# | |
|
||||
# | __ __ _ _ _ _ _ |
|
||||
# | \ \ / /__ _ _ ___| |__ | | ___ _ _ / \ __| | __| | ___ _ __ |
|
||||
# | \ \ / / _ \| | | |/ __| '_ \| |/ _ \ | | | / _ \ / _` |/ _` |/ _ \| '_ \ |
|
||||
# | \ V / (_) | |_| | (__| | | | | __/ |_| | / ___ \ (_| | (_| | (_) | | | | |
|
||||
# | \_/ \___/ \__,_|\___|_| |_|_|\___|\__, | /_/ \_\__,_|\__,_|\___/|_| |_| |
|
||||
# | |___/ |
|
||||
# | |
|
||||
# | Vouchley Addon for SupportBot |
|
||||
# | Created by Emerald Services |
|
||||
# | |
|
||||
# +==================================================================================+
|
||||
|
||||
Vouchley:
|
||||
API_Key: "REQUEST_API_KEY_BELOW"
|
||||
|
||||
# Vouchley API Documentation: https://www.vouchley.com/docs/api/getting-started
|
||||
# To use the API you will need an API key. To get this create a ticket in https://discord.gg/buzz
|
||||
|
||||
Embed:
|
||||
Colour: 0x6063fc # 0x<hexcode> without the #
|
||||
Ephermal: true # Make the profile searches hidden so only the interacting user can see them.
|
||||
|
||||
Command:
|
||||
Name: "vouchley" # The name of the command (/vouchley)
|
||||
Description: "Search a Vouchley Profile." # The description of the command (/vouchley)
|
||||
Permissions: ["SendMessages"]
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"applications": []
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
const Discord = require("discord.js");
|
||||
const { Command } = require("../Structures/Addon.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const axios = require("axios");
|
||||
|
||||
// Load configs
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const minecraftConfig = yaml.load(fs.readFileSync("./Addons/Configs/minecraft.yml", "utf8"));
|
||||
|
||||
module.exports = new Command({
|
||||
name: "serverip",
|
||||
description: "Display the Minecraft server IP and status",
|
||||
options: [
|
||||
{
|
||||
name: "type",
|
||||
description: "The edition of Minecraft (Java or Bedrock)",
|
||||
type: Discord.ApplicationCommandOptionType.String,
|
||||
required: true,
|
||||
choices: [
|
||||
{ name: "Java", value: "java" },
|
||||
{ name: "Bedrock", value: "bedrock" },
|
||||
],
|
||||
},
|
||||
],
|
||||
permissions: ["SendMessages"],
|
||||
|
||||
async run(interaction) {
|
||||
const type = interaction.options.getString("type");
|
||||
const config = type === "java" ? minecraftConfig.Java : minecraftConfig.Bedrock;
|
||||
|
||||
await interaction.deferReply();
|
||||
|
||||
try {
|
||||
const response = await axios.get(`https://api.mcstatus.io/v2/status/${type}/${config.IP}:${config.Port}`);
|
||||
const data = response.data;
|
||||
|
||||
if (!data.online) {
|
||||
throw new Error("Server is offline");
|
||||
}
|
||||
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setTitle(`${config.DisplayName} Status`)
|
||||
.setTimestamp();
|
||||
|
||||
embed.setColor(minecraftConfig.Embed.Color || supportbot.Embed.Colours.General)
|
||||
.setDescription(`The server is currently **Online**!`)
|
||||
.addFields(
|
||||
{ name: "IP Address", value: `\`${config.IP}\``, inline: true },
|
||||
{ name: "Port", value: `\`${config.Port}\``, inline: true },
|
||||
{ name: "Players", value: `\`${data.players.online}/${data.players.max}\``, inline: true },
|
||||
{ name: "Version", value: `\`${type === "java" ? data.version.name_clean : data.version.name || "Unknown"}\``, inline: true }
|
||||
);
|
||||
|
||||
if (data.motd && data.motd.clean) {
|
||||
embed.addFields({ name: "MOTD", value: `\`\`\`${data.motd.clean}\`\`\`` });
|
||||
}
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
} catch (error) {
|
||||
console.error("Error fetching Minecraft status:", error);
|
||||
|
||||
const config = interaction.options.getString("type") === "java" ? minecraftConfig.Java : minecraftConfig.Bedrock;
|
||||
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setTitle(`${config.DisplayName} Status`)
|
||||
.setTimestamp()
|
||||
.setColor(minecraftConfig.Embed.OfflineColor || "#FF0000")
|
||||
.setDescription(`The server is currently **Offline**.`)
|
||||
.addFields(
|
||||
{ name: "IP Address", value: `\`${config.IP}\``, inline: true },
|
||||
{ name: "Port", value: `\`${config.Port}\``, inline: true }
|
||||
);
|
||||
|
||||
await interaction.editReply({ embeds: [embed] });
|
||||
}
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
# SupportBot Sanction Addon
|
||||
# Created by ItsNyoty
|
||||
|
||||
LogChannel: "1275426007980965908"
|
||||
# The ID of the channel where the logs will be sent.
|
||||
@@ -0,0 +1,157 @@
|
||||
const Discord = require('discord.js');
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const { Command } = require('../Structures/Addon.js');
|
||||
const supportbot = yaml.load(fs.readFileSync('./Configs/supportbot.yml', 'utf8'));
|
||||
const sactionaddon = yaml.load(fs.readFileSync('./Addons/Configs/sactionaddon.yml', 'utf8'));
|
||||
|
||||
module.exports = new Command({
|
||||
name: 'sanction',
|
||||
description: 'Sanction a user (kick, ban, or timeout).',
|
||||
options: [
|
||||
{
|
||||
name: 'action',
|
||||
type: 3,
|
||||
description: 'The action to perform (kick, ban, timeout)',
|
||||
required: true,
|
||||
choices: [
|
||||
{ name: 'Kick', value: 'kick' },
|
||||
{ name: 'Ban', value: 'ban' },
|
||||
{ name: 'Timeout', value: 'timeout' }
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'user',
|
||||
type: 6,
|
||||
description: 'The user to sanction',
|
||||
required: true
|
||||
},
|
||||
{
|
||||
name: 'reason',
|
||||
type: 3,
|
||||
description: 'Reason for the sanction',
|
||||
required: false
|
||||
},
|
||||
{
|
||||
name: 'duration',
|
||||
type: 3,
|
||||
description: 'Duration of the timeout (e.g., "10m", "2h", "1d")',
|
||||
required: false
|
||||
}
|
||||
],
|
||||
permissions: [],
|
||||
|
||||
async run(interaction) {
|
||||
const action = interaction.options.getString('action');
|
||||
const user = interaction.options.getUser('user');
|
||||
const reason = interaction.options.getString('reason') || 'No reason provided';
|
||||
const duration = interaction.options.getString('duration');
|
||||
const staffRoleId = supportbot.Roles.StaffMember.Staff;
|
||||
|
||||
const member = interaction.guild.members.cache.get(interaction.user.id);
|
||||
if (!member || !member.roles.cache.has(staffRoleId)) {
|
||||
return interaction.reply({ content: 'You do not have permission to use this command.', ephemeral: true });
|
||||
}
|
||||
|
||||
const targetMember = interaction.guild.members.cache.get(user.id);
|
||||
if (!targetMember) {
|
||||
return interaction.reply({ content: 'The user is not a member of this guild.', ephemeral: true });
|
||||
}
|
||||
|
||||
try {
|
||||
let actionMessage = '';
|
||||
let logEmbed = new Discord.EmbedBuilder().setColor(supportbot.Embed.Colours.General);
|
||||
|
||||
switch (action) {
|
||||
case 'kick':
|
||||
if (!targetMember.kickable) return interaction.reply({ content: 'I cannot kick this user.', ephemeral: true });
|
||||
await targetMember.kick(reason);
|
||||
actionMessage = `Successfully kicked ${user.tag} for: ${reason}`;
|
||||
logEmbed
|
||||
.setTitle('User Kicked')
|
||||
.setDescription(`**User:** ${user.tag}\n**Reason:** ${reason}`)
|
||||
.addFields(
|
||||
{ name: 'Action By', value: interaction.user.tag, inline: true },
|
||||
{ name: 'Action', value: 'Kick', inline: true },
|
||||
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
|
||||
);
|
||||
break;
|
||||
|
||||
case 'ban':
|
||||
if (!targetMember.bannable) return interaction.reply({ content: 'I cannot ban this user.', ephemeral: true });
|
||||
await targetMember.ban({ reason });
|
||||
actionMessage = `Successfully banned ${user.tag} for: ${reason}`;
|
||||
logEmbed
|
||||
.setTitle('User Banned')
|
||||
.setDescription(`**User:** ${user.tag}\n**Reason:** ${reason}`)
|
||||
.addFields(
|
||||
{ name: 'Action By', value: interaction.user.tag, inline: true },
|
||||
{ name: 'Action', value: 'Ban', inline: true },
|
||||
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
|
||||
);
|
||||
break;
|
||||
|
||||
case 'timeout':
|
||||
// Ensure a valid duration is provided
|
||||
if (!duration) return interaction.reply({ content: 'You must specify a duration for the timeout.', ephemeral: true });
|
||||
|
||||
// Convert duration to milliseconds
|
||||
const parsedDuration = ms(duration);
|
||||
if (!parsedDuration) return interaction.reply({ content: 'Invalid duration format. Use "1s", "5m", "2h", or "1d".', ephemeral: true });
|
||||
|
||||
// Apply the timeout
|
||||
await targetMember.timeout(parsedDuration, reason);
|
||||
actionMessage = `Successfully timed out ${user.tag} for ${duration} with reason: ${reason}`;
|
||||
logEmbed
|
||||
.setTitle('User Timed Out')
|
||||
.setDescription(`**User:** ${user.tag}\n**Duration:** ${duration}\n**Reason:** ${reason}`)
|
||||
.addFields(
|
||||
{ name: 'Action By', value: interaction.user.tag, inline: true },
|
||||
{ name: 'Action', value: 'Timeout', inline: true },
|
||||
{ name: 'Timestamp', value: new Date().toISOString(), inline: true }
|
||||
);
|
||||
break;
|
||||
|
||||
default:
|
||||
return interaction.reply({ content: 'Invalid action specified.', ephemeral: true });
|
||||
}
|
||||
|
||||
// Send the action message
|
||||
interaction.reply({ content: actionMessage, ephemeral: true });
|
||||
|
||||
// Log the action to the logging channel
|
||||
const logChannel = interaction.guild.channels.cache.get(sactionaddon.LogChannel);
|
||||
if (logChannel) {
|
||||
logChannel.send({ embeds: [logEmbed] });
|
||||
} else {
|
||||
console.error('Logging channel not found.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error executing sanction command:', error);
|
||||
interaction.reply({ content: 'An error occurred while executing the command.', ephemeral: true });
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Converts a time duration string to milliseconds.
|
||||
* @param {string} duration - The duration string (e.g., "10m", "2h", "1d").
|
||||
* @returns {number} - The duration in milliseconds.
|
||||
*/
|
||||
function ms(duration) {
|
||||
const regex = /^(\d+)([smhd])$/;
|
||||
const match = regex.exec(duration);
|
||||
|
||||
if (!match) return null;
|
||||
|
||||
const value = parseInt(match[1], 10);
|
||||
const unit = match[2];
|
||||
|
||||
switch (unit) {
|
||||
case 's': return value * 1000; // seconds
|
||||
case 'm': return value * 60 * 1000; // minutes
|
||||
case 'h': return value * 60 * 60 * 1000; // hours
|
||||
case 'd': return value * 24 * 60 * 60 * 1000; // days
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
const Discord = require("discord.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const { Command } = require('../Structures/Addon.js'); // Adjust the path as needed
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
|
||||
module.exports = new Command({
|
||||
name: "tags",
|
||||
description: "Retrieve a saved tag.",
|
||||
options: [
|
||||
{
|
||||
name: 'name',
|
||||
type: Discord.ApplicationCommandOptionType.String,
|
||||
description: 'The name of the tag',
|
||||
required: true,
|
||||
autocomplete: true
|
||||
},
|
||||
],
|
||||
permissions: ["SendMessages"],
|
||||
|
||||
async autocomplete(interaction) {
|
||||
const tagsPath = './Addons/Data/tags.json';
|
||||
const focusedValue = interaction.options.getFocused().toLowerCase();
|
||||
|
||||
if (!fs.existsSync(tagsPath)) {
|
||||
return interaction.respond([]);
|
||||
}
|
||||
|
||||
const tags = JSON.parse(fs.readFileSync(tagsPath, 'utf8'));
|
||||
const choices = Object.keys(tags)
|
||||
.filter(tag => tag.toLowerCase().includes(focusedValue))
|
||||
.slice(0, 25) // Discord has a limit of 25 choices
|
||||
.map(tag => ({ name: tag, value: tag }));
|
||||
|
||||
return interaction.respond(choices);
|
||||
},
|
||||
|
||||
async run(interaction) {
|
||||
const tagsPath = './Addons/Data/tags.json';
|
||||
const tagName = interaction.options.getString('name').toLowerCase();
|
||||
|
||||
if (!fs.existsSync(tagsPath)) {
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setDescription('No tags have been created yet.')
|
||||
.setColor(supportbot.Embed.Colours.General);
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
const tags = JSON.parse(fs.readFileSync(tagsPath, 'utf8'));
|
||||
|
||||
if (tags[tagName]) {
|
||||
const tag = tags[tagName];
|
||||
|
||||
if (tag.embed) {
|
||||
// For embeds, Discord automatically handles line breaks correctly
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setDescription(tag.content)
|
||||
.setColor(supportbot.Embed.Colours.General);
|
||||
|
||||
if (tag.image) {
|
||||
embed.setImage(tag.image);
|
||||
}
|
||||
|
||||
return interaction.reply({ embeds: [embed], ephemeral: tag.ephemeral || false });
|
||||
} else {
|
||||
// For plain text, we need to keep the line breaks
|
||||
const response = {
|
||||
content: tag.content,
|
||||
ephemeral: tag.ephemeral || false
|
||||
};
|
||||
|
||||
if (tag.image) {
|
||||
response.files = [tag.image];
|
||||
}
|
||||
|
||||
return interaction.reply(response);
|
||||
}
|
||||
} else {
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setDescription(`Tag "${tagName}" not found.`)
|
||||
.setColor(supportbot.Embed.Colours.General);
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,76 @@
|
||||
const Discord = require("discord.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const { Command } = require('../Structures/Addon.js'); // Adjust the path as needed
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
|
||||
module.exports = new Command({
|
||||
name: "tagscreate",
|
||||
description: "Create a new tag.",
|
||||
options: [
|
||||
{
|
||||
name: 'name',
|
||||
type: Discord.ApplicationCommandOptionType.String,
|
||||
description: 'The name of the tag',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'content',
|
||||
type: Discord.ApplicationCommandOptionType.String,
|
||||
description: 'The content of the tag (use ; or /n for new lines)',
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: 'image',
|
||||
type: Discord.ApplicationCommandOptionType.Attachment,
|
||||
description: 'The image URL of the tag',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'embed',
|
||||
type: Discord.ApplicationCommandOptionType.Boolean,
|
||||
description: 'Do you want this tag to send as an embed',
|
||||
required: false,
|
||||
},
|
||||
{
|
||||
name: 'ephemeral',
|
||||
type: Discord.ApplicationCommandOptionType.Boolean,
|
||||
description: 'Do you want this tag to send as ephemeral',
|
||||
required: false,
|
||||
},
|
||||
],
|
||||
permissions: ["SendMessages"],
|
||||
|
||||
async run(interaction) {
|
||||
const tagName = interaction.options.getString('name').toLowerCase();
|
||||
let tagContent = interaction.options.getString('content');
|
||||
const tagImage = interaction.options.getAttachment('image');
|
||||
const isEmbed = interaction.options.getBoolean('embed');
|
||||
const isEphemeral = interaction.options.getBoolean('ephemeral');
|
||||
const tagsPath = './Addons/Data/tags.json';
|
||||
|
||||
// Process line breaks in content - support both ; and /n notations
|
||||
tagContent = tagContent.replace(/;/g, '\n').replace(/\/n/g, '\n');
|
||||
|
||||
let tags = {};
|
||||
|
||||
if (fs.existsSync(tagsPath)) {
|
||||
tags = JSON.parse(fs.readFileSync(tagsPath, 'utf8'));
|
||||
}
|
||||
|
||||
tags[tagName] = {
|
||||
content: tagContent,
|
||||
image: tagImage ? tagImage.url : null,
|
||||
embed: isEmbed || false,
|
||||
ephemeral: isEphemeral || false,
|
||||
};
|
||||
|
||||
fs.writeFileSync(tagsPath, JSON.stringify(tags, null, 4));
|
||||
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setDescription(`Tag "${tagName}" created successfully.`)
|
||||
.setColor(supportbot.Embed.Colours.General);
|
||||
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
const Discord = require("discord.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const { Command } = require('../Structures/Addon.js'); // Adjust the path as needed
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
|
||||
module.exports = new Command({
|
||||
name: "tagsdelete",
|
||||
description: "Delete an existing tag.",
|
||||
options: [
|
||||
{
|
||||
name: 'name',
|
||||
type: Discord.ApplicationCommandOptionType.String,
|
||||
description: 'The name of the tag to delete',
|
||||
required: true,
|
||||
autocomplete: true
|
||||
},
|
||||
],
|
||||
permissions: ["SendMessages"],
|
||||
|
||||
async autocomplete(interaction) {
|
||||
const tagsPath = './Addons/Data/tags.json';
|
||||
const focusedValue = interaction.options.getFocused().toLowerCase();
|
||||
|
||||
if (!fs.existsSync(tagsPath)) {
|
||||
return interaction.respond([]);
|
||||
}
|
||||
|
||||
const tags = JSON.parse(fs.readFileSync(tagsPath, 'utf8'));
|
||||
const choices = Object.keys(tags)
|
||||
.filter(tag => tag.toLowerCase().includes(focusedValue))
|
||||
.slice(0, 25) // Discord has a limit of 25 choices
|
||||
.map(tag => ({ name: tag, value: tag }));
|
||||
|
||||
return interaction.respond(choices);
|
||||
},
|
||||
|
||||
async run(interaction) {
|
||||
const tagsPath = './Addons/Data/tags.json';
|
||||
const tagName = interaction.options.getString('name').toLowerCase();
|
||||
|
||||
if (!fs.existsSync(tagsPath)) {
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setDescription('No tags have been created yet.')
|
||||
.setColor(supportbot.Embed.Colours.General);
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
let tags = JSON.parse(fs.readFileSync(tagsPath, 'utf8'));
|
||||
|
||||
if (!tags[tagName]) {
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setDescription(`Tag "${tagName}" not found.`)
|
||||
.setColor(supportbot.Embed.Colours.General);
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
|
||||
delete tags[tagName];
|
||||
|
||||
fs.writeFileSync(tagsPath, JSON.stringify(tags, null, 4));
|
||||
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setDescription(`Tag "${tagName}" deleted successfully.`)
|
||||
.setColor(supportbot.Embed.Colours.General);
|
||||
|
||||
return interaction.reply({ embeds: [embed], ephemeral: true });
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
PayPal:
|
||||
ClientID: "clientID_Here"
|
||||
ClientSecret: "ClientSecret_Here"
|
||||
Mode: "live" # or "live"
|
||||
Email: "Email_Here" # Add your PayPal email here
|
||||
Embed:
|
||||
Colours:
|
||||
General: "#0099ff"
|
||||
Success: "#00ff00" # Add more colors if needed
|
||||
Error: "#ff0000" # Add more colors if needed
|
||||
Footer: "PayPal Invoice" # Add footer text here
|
||||
Logo: "https://your-logo-url.com/logo.png" # Add logo URL if used in invoice
|
||||
Note: "Thank you for your business!" # Add a note if used in invoice
|
||||
TOS: "Please review our terms and conditions. https://domain.com/tos" # Add terms of service if used in invoice
|
||||
@@ -0,0 +1,146 @@
|
||||
const { EmbedBuilder, ButtonBuilder, ButtonStyle, ActionRowBuilder, ApplicationCommandOptionType } = require('discord.js');
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
const paypal = require('paypal-rest-sdk');
|
||||
const { Command } = require('../Structures/Addon.js'); // Adjust the path as needed
|
||||
|
||||
// Load the PayPal configuration
|
||||
const config = yaml.load(fs.readFileSync("./Addons/Configs/paypal.yml", "utf8"));
|
||||
const supportbot = config;
|
||||
|
||||
// Configure PayPal SDK
|
||||
paypal.configure({
|
||||
mode: supportbot.PayPal.Mode, // sandbox or live
|
||||
client_id: supportbot.PayPal.ClientID,
|
||||
client_secret: supportbot.PayPal.ClientSecret
|
||||
});
|
||||
|
||||
module.exports = new Command({
|
||||
name: "invoice",
|
||||
description: "Generate a PayPal invoice for a user.",
|
||||
options: [
|
||||
{
|
||||
name: "user",
|
||||
description: "The user to notify.",
|
||||
type: ApplicationCommandOptionType.User,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "price",
|
||||
description: "The amount to invoice.",
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "service",
|
||||
description: "The service provided.",
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required: true,
|
||||
},
|
||||
{
|
||||
name: "email",
|
||||
description: "The email to invoice.",
|
||||
type: ApplicationCommandOptionType.String,
|
||||
required: true,
|
||||
}
|
||||
],
|
||||
permissions: ["Administrator"],
|
||||
|
||||
async run(interaction) {
|
||||
const user = interaction.options.getUser("user");
|
||||
const price = interaction.options.getString("price");
|
||||
const service = interaction.options.getString("service");
|
||||
const email = interaction.options.getString("email");
|
||||
|
||||
// Validate the price format
|
||||
if (isNaN(price) || parseFloat(price) <= 0) {
|
||||
const errorEmbed = new EmbedBuilder()
|
||||
.setDescription(`The price provided is invalid.`)
|
||||
.setColor(supportbot.Embed.Colours.Error)
|
||||
.setFooter({ text: supportbot.Embed.Footer });
|
||||
return interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
||||
}
|
||||
|
||||
const invoiceData = {
|
||||
"merchant_info": {
|
||||
"email": supportbot.PayPal.Email,
|
||||
"business_name": interaction.guild.name
|
||||
},
|
||||
"billing_info": [{
|
||||
"email": email
|
||||
}],
|
||||
"items": [{
|
||||
"name": service,
|
||||
"quantity": 1,
|
||||
"unit_price": {
|
||||
"currency": "USD",
|
||||
"value": price
|
||||
}
|
||||
}],
|
||||
"logo_url": supportbot.PayPal.Logo,
|
||||
"note": supportbot.PayPal.Note,
|
||||
"terms": supportbot.PayPal.TOS,
|
||||
"payment_term": {
|
||||
"term_type": "NET_45"
|
||||
},
|
||||
"tax_inclusive": false,
|
||||
"total_amount": {
|
||||
"currency": "USD",
|
||||
"value": price
|
||||
}
|
||||
};
|
||||
|
||||
paypal.invoice.create(invoiceData, function (error, invoice) {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
const errorEmbed = new EmbedBuilder()
|
||||
.setTitle(`PayPal Error`)
|
||||
.setDescription(`There was an error creating the invoice. Please check your configuration and try again.`)
|
||||
.setColor(supportbot.Embed.Colours.Error)
|
||||
.setFooter({ text: supportbot.Embed.Footer });
|
||||
return interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
||||
}
|
||||
|
||||
const invoiceId = invoice.id;
|
||||
paypal.invoice.send(invoiceId, function (error) {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
const errorEmbed = new EmbedBuilder()
|
||||
.setTitle(`PayPal Error`)
|
||||
.setDescription(`There was an error sending the invoice. Please check your configuration and try again.`)
|
||||
.setColor(supportbot.Embed.Colours.Error)
|
||||
.setFooter({ text: supportbot.Embed.Footer });
|
||||
return interaction.reply({ embeds: [errorEmbed], ephemeral: true });
|
||||
}
|
||||
|
||||
const invoiceEmbed = new EmbedBuilder()
|
||||
.setTitle(`Payment System | ${interaction.guild.name}`)
|
||||
.addFields(
|
||||
{ name: 'Service', value: service, inline: true },
|
||||
{ name: 'Amount Due', value: `$${price}`, inline: true },
|
||||
{ name: 'Currency', value: 'USD', inline: true },
|
||||
{ name: 'Invoice ID', value: `\`${invoiceId}\``, inline: false }
|
||||
)
|
||||
.setColor(supportbot.Embed.Colours.General)
|
||||
.setFooter({ text: supportbot.Embed.Footer })
|
||||
.setTimestamp();
|
||||
|
||||
// Create a button with a link to the invoice
|
||||
const invoiceButton = new ButtonBuilder()
|
||||
.setLabel('View Invoice')
|
||||
.setStyle(ButtonStyle.Link)
|
||||
.setURL(`https://www.paypal.com/invoice/payerView/details/${invoiceId}`);
|
||||
|
||||
// Create the action row
|
||||
const actionRow = new ActionRowBuilder()
|
||||
.addComponents(invoiceButton);
|
||||
|
||||
// Send the embed with the button
|
||||
interaction.reply({
|
||||
embeds: [invoiceEmbed],
|
||||
components: [actionRow]
|
||||
});
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,227 @@
|
||||
const Discord = require("discord.js");
|
||||
const { Event } = require("../Structures/Addon.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const tempVCConfig = yaml.load(fs.readFileSync("./Addons/Configs/tempvc.yml", "utf8"));
|
||||
|
||||
const tempChannels = new Map();
|
||||
|
||||
const voiceStateUpdate = new Event("voiceStateUpdate", async (client, oldState, newState) => {
|
||||
if (!tempVCConfig.Enabled) return;
|
||||
|
||||
const member = newState.member;
|
||||
const guild = newState.guild;
|
||||
|
||||
if (newState.channelId === tempVCConfig.JoinToCreateChannelID) {
|
||||
console.log(`[TempVC] ${member.user.tag} joined the Join-to-Create channel.`);
|
||||
}
|
||||
|
||||
if (newState.channelId === tempVCConfig.JoinToCreateChannelID) {
|
||||
try {
|
||||
const parentCategory = tempVCConfig.CategoryID || newState.channel.parentId;
|
||||
const channelName = tempVCConfig.ChannelNamePattern.replace("{user}", member.user.username);
|
||||
|
||||
const newChannel = await guild.channels.create({
|
||||
name: channelName,
|
||||
type: Discord.ChannelType.GuildVoice,
|
||||
parent: parentCategory,
|
||||
permissionOverwrites: [
|
||||
{
|
||||
id: member.id,
|
||||
allow: [Discord.PermissionFlagsBits.ManageChannels, Discord.PermissionFlagsBits.MoveMembers],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
tempChannels.set(newChannel.id, member.id);
|
||||
await member.voice.setChannel(newChannel);
|
||||
|
||||
console.log(`[TempVC] Created channel "${channelName}" for ${member.user.tag}`);
|
||||
|
||||
if (tempVCConfig.Hub.Enabled && tempVCConfig.Hub.SendOnCreate) {
|
||||
try {
|
||||
const { ActionRowBuilder, ButtonBuilder, EmbedBuilder, MessageFlags, ButtonStyle } = Discord;
|
||||
const layoutStyle = tempVCConfig.Hub.LayoutStyle || "classic";
|
||||
|
||||
if (layoutStyle === "classic") {
|
||||
const config = tempVCConfig.Hub.Classic;
|
||||
const embed = new EmbedBuilder()
|
||||
.setTitle(config.Title)
|
||||
.setDescription(config.Description)
|
||||
.setColor(config.Color);
|
||||
|
||||
const buttons = new ActionRowBuilder().addComponents(
|
||||
new ButtonBuilder().setCustomId("tempvc_lock").setLabel("Lock").setEmoji("🔒").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("tempvc_unlock").setLabel("Unlock").setEmoji("🔓").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("tempvc_rename").setLabel("Rename").setEmoji("✏️").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("tempvc_limit").setLabel("Limit").setEmoji("👥").setStyle(ButtonStyle.Secondary),
|
||||
new ButtonBuilder().setCustomId("tempvc_claim").setLabel("Claim Ownership").setEmoji("👑").setStyle(ButtonStyle.Primary)
|
||||
);
|
||||
|
||||
await newChannel.send({ embeds: [embed], components: [buttons] });
|
||||
} else {
|
||||
const config = tempVCConfig.Hub.Modern;
|
||||
const colorInt = parseInt(config.Color.replace("#", ""), 16);
|
||||
|
||||
const container = {
|
||||
type: 11,
|
||||
accent_color: colorInt,
|
||||
children: [
|
||||
{
|
||||
type: 12,
|
||||
children: [
|
||||
{ type: 13, content: `## ${config.Title}` },
|
||||
{ type: 13, content: config.Body }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 15,
|
||||
divider: true
|
||||
},
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
{ type: 2, custom_id: "tempvc_lock", label: "Lock", emoji: { name: "🔒" }, style: 2 },
|
||||
{ type: 2, custom_id: "tempvc_unlock", label: "Unlock", emoji: { name: "🔓" }, style: 2 },
|
||||
{ type: 2, custom_id: "tempvc_rename", label: "Rename", emoji: { name: "✏️" }, style: 2 },
|
||||
{ type: 2, custom_id: "tempvc_limit", label: "Limit", emoji: { name: "👥" }, style: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
type: 1,
|
||||
components: [
|
||||
{ type: 2, custom_id: "tempvc_claim", label: "Claim Ownership", emoji: { name: "👑" }, style: 1 }
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
await newChannel.send({
|
||||
flags: MessageFlags.IsComponentsV2,
|
||||
components: [container]
|
||||
});
|
||||
}
|
||||
} catch (hubError) {
|
||||
console.error("[TempVC] Error sending Hub:", hubError);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[TempVC] Error creating temporary channel:", error);
|
||||
}
|
||||
}
|
||||
|
||||
if (oldState.channelId && tempChannels.has(oldState.channelId)) {
|
||||
const oldChannel = oldState.channel;
|
||||
|
||||
if (oldChannel && oldChannel.members.size === 0) {
|
||||
try {
|
||||
await oldChannel.delete();
|
||||
tempChannels.delete(oldState.channelId);
|
||||
console.log(`[TempVC] Deleted empty temporary channel "${oldChannel.name}"`);
|
||||
} catch (error) {
|
||||
if (error.code !== 10003) {
|
||||
console.error("[TempVC] Error deleting temporary channel:", error);
|
||||
}
|
||||
tempChannels.delete(oldState.channelId);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const interactionEvent = new Event("interactionCreate", async (client, interaction) => {
|
||||
if (!interaction.isButton() && !interaction.isModalSubmit()) return;
|
||||
if (!interaction.customId.startsWith("tempvc_")) return;
|
||||
|
||||
const channel = interaction.channel;
|
||||
if (!tempChannels.has(channel.id)) {
|
||||
return interaction.reply({ content: "This channel is no longer a temporary voice channel.", flags: Discord.MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
const ownerId = tempChannels.get(channel.id);
|
||||
const isOwner = interaction.user.id === ownerId || interaction.member.permissions.has(Discord.PermissionFlagsBits.Administrator);
|
||||
|
||||
if (interaction.customId === "tempvc_claim") {
|
||||
const currentOwnerInVC = channel.members.has(ownerId);
|
||||
if (currentOwnerInVC && interaction.user.id !== ownerId) {
|
||||
return interaction.reply({ content: "The owner is still in the voice channel!", flags: Discord.MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
tempChannels.set(channel.id, interaction.user.id);
|
||||
await channel.permissionOverwrites.edit(interaction.user.id, {
|
||||
ManageChannels: true,
|
||||
MoveMembers: true,
|
||||
});
|
||||
|
||||
return interaction.reply({ content: "You are now the owner of this voice channel!", flags: Discord.MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
if (!isOwner) {
|
||||
return interaction.reply({ content: "Only the channel owner can use these controls.", flags: Discord.MessageFlags.Ephemeral });
|
||||
}
|
||||
|
||||
try {
|
||||
switch (interaction.customId) {
|
||||
case "tempvc_lock":
|
||||
await channel.permissionOverwrites.edit(interaction.guild.id, { Connect: false });
|
||||
await interaction.reply({ content: "Channel locked! 🔒", flags: Discord.MessageFlags.Ephemeral });
|
||||
break;
|
||||
case "tempvc_unlock":
|
||||
await channel.permissionOverwrites.edit(interaction.guild.id, { Connect: null });
|
||||
await interaction.reply({ content: "Channel unlocked! 🔓", flags: Discord.MessageFlags.Ephemeral });
|
||||
break;
|
||||
case "tempvc_rename":
|
||||
const modal = new Discord.ModalBuilder()
|
||||
.setCustomId("tempvc_modal_rename")
|
||||
.setTitle("Rename Channel")
|
||||
.addComponents(
|
||||
new Discord.ActionRowBuilder().addComponents(
|
||||
new Discord.TextInputBuilder()
|
||||
.setCustomId("name")
|
||||
.setLabel("New Name")
|
||||
.setStyle(Discord.TextInputStyle.Short)
|
||||
.setPlaceholder(channel.name)
|
||||
.setRequired(true)
|
||||
)
|
||||
);
|
||||
await interaction.showModal(modal);
|
||||
break;
|
||||
case "tempvc_limit":
|
||||
const limitModal = new Discord.ModalBuilder()
|
||||
.setCustomId("tempvc_modal_limit")
|
||||
.setTitle("Set User Limit")
|
||||
.addComponents(
|
||||
new Discord.ActionRowBuilder().addComponents(
|
||||
new Discord.TextInputBuilder()
|
||||
.setCustomId("limit")
|
||||
.setLabel("Limit (0-99)")
|
||||
.setStyle(Discord.TextInputStyle.Short)
|
||||
.setPlaceholder("0 for no limit")
|
||||
.setRequired(true)
|
||||
)
|
||||
);
|
||||
await interaction.showModal(limitModal);
|
||||
break;
|
||||
}
|
||||
|
||||
if (interaction.isModalSubmit()) {
|
||||
if (interaction.customId === "tempvc_modal_rename") {
|
||||
const newName = interaction.fields.getTextInputValue("name");
|
||||
await channel.setName(newName);
|
||||
await interaction.reply({ content: `Channel renamed to **${newName}**!`, flags: Discord.MessageFlags.Ephemeral });
|
||||
} else if (interaction.customId === "tempvc_modal_limit") {
|
||||
const limit = parseInt(interaction.fields.getTextInputValue("limit"));
|
||||
if (isNaN(limit) || limit < 0 || limit > 99) {
|
||||
return interaction.reply({ content: "Please enter a valid number between 0 and 99.", flags: Discord.MessageFlags.Ephemeral });
|
||||
}
|
||||
await channel.setUserLimit(limit);
|
||||
await interaction.reply({ content: `User limit set to **${limit === 0 ? "Unlimited" : limit}**!`, flags: Discord.MessageFlags.Ephemeral });
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[TempVC] Error handling interaction:", error);
|
||||
if (!interaction.replied) await interaction.reply({ content: "An error occurred.", flags: Discord.MessageFlags.Ephemeral });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = [voiceStateUpdate, interactionEvent];
|
||||
+1
-1
@@ -2,7 +2,7 @@ const Discord = require("discord.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
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({
|
||||
name: "addon",
|
||||
|
||||
@@ -0,0 +1,652 @@
|
||||
const Discord = require("discord.js");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const { Command } = require("../Structures/Addon.js");
|
||||
|
||||
// Main bot config
|
||||
const supportbot = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
||||
);
|
||||
|
||||
// Addon config
|
||||
const addonConfig = yaml.load(
|
||||
fs.readFileSync("./Addons/Configs/applicationsystem.yml", "utf8")
|
||||
);
|
||||
|
||||
// Data file
|
||||
const dataPath = path.join(__dirname, "Data/applicationsystem.json");
|
||||
|
||||
function ensureDataFile() {
|
||||
const dir = path.dirname(dataPath);
|
||||
|
||||
if (!fs.existsSync(dir)) {
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dataPath)) {
|
||||
fs.writeFileSync(
|
||||
dataPath,
|
||||
JSON.stringify({ applications: [] }, null, 2),
|
||||
"utf8"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function readData() {
|
||||
ensureDataFile();
|
||||
|
||||
try {
|
||||
const raw = fs.readFileSync(dataPath, "utf8");
|
||||
const parsed = JSON.parse(raw);
|
||||
|
||||
if (!parsed || !Array.isArray(parsed.applications)) {
|
||||
return { applications: [] };
|
||||
}
|
||||
|
||||
return parsed;
|
||||
} catch (error) {
|
||||
console.error("[ApplicationSystem] Failed to read data file:", error);
|
||||
return { applications: [] };
|
||||
}
|
||||
}
|
||||
|
||||
function writeData(data) {
|
||||
ensureDataFile();
|
||||
fs.writeFileSync(dataPath, JSON.stringify(data, null, 2), "utf8");
|
||||
}
|
||||
|
||||
function now() {
|
||||
return Date.now();
|
||||
}
|
||||
|
||||
function createId() {
|
||||
return `app_${Date.now()}_${Math.floor(Math.random() * 100000)}`;
|
||||
}
|
||||
|
||||
function parseColor(value, fallback = 0x2b2d31) {
|
||||
if (typeof value === "number") return value;
|
||||
|
||||
if (typeof value === "string") {
|
||||
const cleaned = value.trim().replace("#", "");
|
||||
if (/^[0-9A-Fa-f]{6}$/.test(cleaned)) {
|
||||
return parseInt(cleaned, 16);
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function getGeneralColour() {
|
||||
return parseColor(supportbot?.Embed?.Colours?.General, 0x2b2d31);
|
||||
}
|
||||
|
||||
function buildNoticeContainer(title, description, colour = 0x5865f2) {
|
||||
return new Discord.ContainerBuilder()
|
||||
.setAccentColor(colour)
|
||||
.addTextDisplayComponents(
|
||||
new Discord.TextDisplayBuilder().setContent(`## ${title}`)
|
||||
)
|
||||
.addSeparatorComponents(new Discord.SeparatorBuilder())
|
||||
.addTextDisplayComponents(
|
||||
new Discord.TextDisplayBuilder().setContent(description)
|
||||
);
|
||||
}
|
||||
|
||||
function buildPanelContainer() {
|
||||
const options = Object.entries(addonConfig.Applications || {})
|
||||
.slice(0, 25)
|
||||
.map(([key, app]) =>
|
||||
new Discord.StringSelectMenuOptionBuilder()
|
||||
.setLabel(String(app.Name || key).slice(0, 100))
|
||||
.setDescription(
|
||||
String(app.Description || "Apply for this position").slice(0, 100)
|
||||
)
|
||||
.setValue(key)
|
||||
);
|
||||
|
||||
return new Discord.ContainerBuilder()
|
||||
.setAccentColor(getGeneralColour())
|
||||
.addTextDisplayComponents(
|
||||
new Discord.TextDisplayBuilder().setContent(
|
||||
`## ${addonConfig.Panel?.Title || "Applications"}`
|
||||
)
|
||||
)
|
||||
.addSeparatorComponents(new Discord.SeparatorBuilder())
|
||||
.addTextDisplayComponents(
|
||||
new Discord.TextDisplayBuilder().setContent(
|
||||
addonConfig.Panel?.Description ||
|
||||
"Choose an application from the dropdown below."
|
||||
)
|
||||
)
|
||||
.addActionRowComponents(
|
||||
new Discord.ActionRowBuilder().addComponents(
|
||||
new Discord.StringSelectMenuBuilder()
|
||||
.setCustomId("appsystem_select")
|
||||
.setPlaceholder(
|
||||
addonConfig.Panel?.Placeholder || "Select an application type"
|
||||
)
|
||||
.addOptions(options)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function textInputStyle(style) {
|
||||
return style === "Paragraph"
|
||||
? Discord.TextInputStyle.Paragraph
|
||||
: Discord.TextInputStyle.Short;
|
||||
}
|
||||
|
||||
function sanitizeQuestionKey(key) {
|
||||
return String(key).toLowerCase().replace(/[^a-z0-9_]/g, "_");
|
||||
}
|
||||
|
||||
function formatQuestionTitle(key) {
|
||||
return String(key)
|
||||
.replace(/_/g, " ")
|
||||
.replace(/\b\w/g, (char) => char.toUpperCase());
|
||||
}
|
||||
|
||||
function buildApplicationModal(typeKey) {
|
||||
const applicationType = addonConfig.Applications?.[typeKey];
|
||||
if (!applicationType) return null;
|
||||
|
||||
const modal = new Discord.ModalBuilder()
|
||||
.setCustomId(`appsystem_modal_${typeKey}`)
|
||||
.setTitle(String(applicationType.Name || typeKey).slice(0, 45));
|
||||
|
||||
const questionEntries = Object.entries(applicationType.Questions || {}).slice(0, 5);
|
||||
|
||||
for (const [key, question] of questionEntries) {
|
||||
const input = new Discord.TextInputBuilder()
|
||||
.setCustomId(sanitizeQuestionKey(key))
|
||||
.setLabel(String(question.Label || key).slice(0, 45))
|
||||
.setPlaceholder(String(question.Placeholder || "Enter your answer").slice(0, 100))
|
||||
.setRequired(Boolean(question.Required))
|
||||
.setStyle(textInputStyle(question.Style))
|
||||
.setMinLength(Number(question.MinLength || 1))
|
||||
.setMaxLength(Number(question.MaxLength || 1000));
|
||||
|
||||
modal.addComponents(
|
||||
new Discord.ActionRowBuilder().addComponents(input)
|
||||
);
|
||||
}
|
||||
|
||||
return modal;
|
||||
}
|
||||
|
||||
function buildReviewContainer(application, reviewerText = null, locked = false) {
|
||||
const statusText =
|
||||
application.status === "accepted"
|
||||
? "Accepted"
|
||||
: application.status === "rejected"
|
||||
? "Rejected"
|
||||
: "Pending";
|
||||
|
||||
const answerLines = Object.entries(application.answers || {})
|
||||
.map(([key, value]) => `### ${formatQuestionTitle(key)}\n${value}`)
|
||||
.join("\n\n");
|
||||
|
||||
const body = [
|
||||
`**Applicant:** <@${application.userId}>`,
|
||||
`**Tag:** ${application.userTag}`,
|
||||
`**Application Type:** ${application.typeName}`,
|
||||
`**Application ID:** \`${application.id}\``,
|
||||
`**Status:** ${statusText}`,
|
||||
"",
|
||||
answerLines || "*No answers provided.*",
|
||||
].join("\n");
|
||||
|
||||
const container = new Discord.ContainerBuilder()
|
||||
.setAccentColor(getGeneralColour())
|
||||
.addTextDisplayComponents(
|
||||
new Discord.TextDisplayBuilder().setContent(
|
||||
`## ${addonConfig.Review?.Title || "New Application"}`
|
||||
)
|
||||
)
|
||||
.addSeparatorComponents(new Discord.SeparatorBuilder())
|
||||
.addTextDisplayComponents(
|
||||
new Discord.TextDisplayBuilder().setContent(body)
|
||||
);
|
||||
|
||||
if (reviewerText) {
|
||||
container
|
||||
.addSeparatorComponents(new Discord.SeparatorBuilder())
|
||||
.addTextDisplayComponents(
|
||||
new Discord.TextDisplayBuilder().setContent(reviewerText)
|
||||
);
|
||||
}
|
||||
|
||||
container.addActionRowComponents(
|
||||
new Discord.ActionRowBuilder().addComponents(
|
||||
new Discord.ButtonBuilder()
|
||||
.setCustomId(`appsystem_accept_${application.id}`)
|
||||
.setLabel(addonConfig.Review?.AcceptLabel || "Accept")
|
||||
.setStyle(Discord.ButtonStyle.Success)
|
||||
.setDisabled(locked),
|
||||
new Discord.ButtonBuilder()
|
||||
.setCustomId(`appsystem_reject_${application.id}`)
|
||||
.setLabel(addonConfig.Review?.RejectLabel || "Reject")
|
||||
.setStyle(Discord.ButtonStyle.Danger)
|
||||
.setDisabled(locked)
|
||||
)
|
||||
);
|
||||
|
||||
return container;
|
||||
}
|
||||
|
||||
function getPendingApplication(data, guildId, userId, type) {
|
||||
return data.applications.find(
|
||||
(app) =>
|
||||
app.guildId === guildId &&
|
||||
app.userId === userId &&
|
||||
app.type === type &&
|
||||
app.status === "pending"
|
||||
);
|
||||
}
|
||||
|
||||
function getCooldownApplication(data, guildId, userId, type) {
|
||||
const cooldownMs =
|
||||
Number(addonConfig.Application?.CooldownHours || 24) * 60 * 60 * 1000;
|
||||
|
||||
return data.applications.find(
|
||||
(app) =>
|
||||
app.guildId === guildId &&
|
||||
app.userId === userId &&
|
||||
app.type === type &&
|
||||
now() - Number(app.createdAt || 0) < cooldownMs
|
||||
);
|
||||
}
|
||||
|
||||
function memberCanReview(member) {
|
||||
if (!member) return false;
|
||||
|
||||
if (member.permissions?.has(Discord.PermissionFlagsBits.ManageGuild)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function registerAddonInteractions(client) {
|
||||
if (client.__applicationSystemRegistered) return;
|
||||
client.__applicationSystemRegistered = true;
|
||||
|
||||
client.on("interactionCreate", async (interaction) => {
|
||||
try {
|
||||
if (!interaction.guild) return;
|
||||
|
||||
// Application dropdown select
|
||||
if (
|
||||
interaction.isStringSelectMenu() &&
|
||||
interaction.customId === "appsystem_select"
|
||||
) {
|
||||
const typeKey = interaction.values[0];
|
||||
const applicationType = addonConfig.Applications?.[typeKey];
|
||||
|
||||
if (!applicationType) {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Invalid Application",
|
||||
"That application type does not exist.",
|
||||
0xed4245
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const data = readData();
|
||||
|
||||
if (addonConfig.Application?.OneOpenApplicationPerUserPerType) {
|
||||
const existing = getPendingApplication(
|
||||
data,
|
||||
interaction.guild.id,
|
||||
interaction.user.id,
|
||||
typeKey
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Application Already Open",
|
||||
`You already have a pending ${applicationType.Name} application.`,
|
||||
0xed4245
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const cooldownHit = getCooldownApplication(
|
||||
data,
|
||||
interaction.guild.id,
|
||||
interaction.user.id,
|
||||
typeKey
|
||||
);
|
||||
|
||||
if (cooldownHit && cooldownHit.status !== "pending") {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Please Wait",
|
||||
`You must wait ${addonConfig.Application?.CooldownHours || 24} hours before applying again for ${applicationType.Name}.`,
|
||||
0xfee75c
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const modal = buildApplicationModal(typeKey);
|
||||
|
||||
if (!modal) {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Modal Error",
|
||||
"The application form could not be created.",
|
||||
0xed4245
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
return interaction.showModal(modal);
|
||||
}
|
||||
|
||||
// Application modal submit
|
||||
if (
|
||||
interaction.isModalSubmit() &&
|
||||
interaction.customId.startsWith("appsystem_modal_")
|
||||
) {
|
||||
const typeKey = interaction.customId.replace("appsystem_modal_", "");
|
||||
const applicationType = addonConfig.Applications?.[typeKey];
|
||||
|
||||
if (!applicationType) {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Invalid Application",
|
||||
"That application type no longer exists.",
|
||||
0xed4245
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const reviewChannel = interaction.guild.channels.cache.get(
|
||||
addonConfig.Application?.ReviewChannelId
|
||||
);
|
||||
|
||||
if (!reviewChannel) {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Configuration Error",
|
||||
"The review channel was not found. Please contact staff.",
|
||||
0xed4245
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const data = readData();
|
||||
|
||||
if (addonConfig.Application?.OneOpenApplicationPerUserPerType) {
|
||||
const existing = getPendingApplication(
|
||||
data,
|
||||
interaction.guild.id,
|
||||
interaction.user.id,
|
||||
typeKey
|
||||
);
|
||||
|
||||
if (existing) {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Application Already Open",
|
||||
`You already have a pending ${applicationType.Name} application.`,
|
||||
0xed4245
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const answers = {};
|
||||
const questionEntries = Object.entries(applicationType.Questions || {}).slice(0, 5);
|
||||
|
||||
for (const [key] of questionEntries) {
|
||||
const inputKey = sanitizeQuestionKey(key);
|
||||
answers[inputKey] = interaction.fields.getTextInputValue(inputKey);
|
||||
}
|
||||
|
||||
const application = {
|
||||
id: createId(),
|
||||
type: typeKey,
|
||||
typeName: applicationType.Name || typeKey,
|
||||
guildId: interaction.guild.id,
|
||||
userId: interaction.user.id,
|
||||
userTag: interaction.user.tag || interaction.user.username,
|
||||
status: "pending",
|
||||
createdAt: now(),
|
||||
reviewerId: null,
|
||||
reviewedAt: null,
|
||||
reviewMessageId: null,
|
||||
answers,
|
||||
};
|
||||
|
||||
const reviewMessage = await reviewChannel.send({
|
||||
flags: Discord.MessageFlags.IsComponentsV2,
|
||||
components: [buildReviewContainer(application, null, false)],
|
||||
});
|
||||
|
||||
application.reviewMessageId = reviewMessage.id;
|
||||
data.applications.push(application);
|
||||
writeData(data);
|
||||
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Application Sent",
|
||||
`Your ${applicationType.Name} application has been submitted successfully.`,
|
||||
0x57f287
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Review buttons
|
||||
if (
|
||||
interaction.isButton() &&
|
||||
(interaction.customId.startsWith("appsystem_accept_") ||
|
||||
interaction.customId.startsWith("appsystem_reject_"))
|
||||
) {
|
||||
if (!memberCanReview(interaction.member)) {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"No Permission",
|
||||
"You do not have permission to review applications.",
|
||||
0xed4245
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
const isAccept = interaction.customId.startsWith("appsystem_accept_");
|
||||
const applicationId = interaction.customId.replace(
|
||||
isAccept ? "appsystem_accept_" : "appsystem_reject_",
|
||||
""
|
||||
);
|
||||
|
||||
const data = readData();
|
||||
const application = data.applications.find((app) => app.id === applicationId);
|
||||
|
||||
if (!application) {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Not Found",
|
||||
"This application no longer exists in storage.",
|
||||
0xed4245
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (application.status !== "pending") {
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Already Reviewed",
|
||||
`This application has already been ${application.status}.`,
|
||||
0xfee75c
|
||||
),
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
application.status = isAccept ? "accepted" : "rejected";
|
||||
application.reviewerId = interaction.user.id;
|
||||
application.reviewedAt = now();
|
||||
writeData(data);
|
||||
|
||||
const reviewerText = [
|
||||
`### Review Result`,
|
||||
`**Decision:** ${isAccept ? "Accepted" : "Rejected"}`,
|
||||
`**Reviewer:** <@${interaction.user.id}>`,
|
||||
`**Reviewed At:** <t:${Math.floor(application.reviewedAt / 1000)}:F>`,
|
||||
].join("\n");
|
||||
|
||||
await interaction.update({
|
||||
flags: Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildReviewContainer(application, reviewerText, true),
|
||||
],
|
||||
});
|
||||
|
||||
const applicationType = addonConfig.Applications?.[application.type];
|
||||
|
||||
if (isAccept && applicationType?.AcceptedRoleId) {
|
||||
const member = await interaction.guild.members
|
||||
.fetch(application.userId)
|
||||
.catch(() => null);
|
||||
|
||||
if (member) {
|
||||
await member.roles.add(applicationType.AcceptedRoleId).catch(() => null);
|
||||
}
|
||||
}
|
||||
|
||||
const applicantUser = await interaction.client.users
|
||||
.fetch(application.userId)
|
||||
.catch(() => null);
|
||||
|
||||
if (applicantUser) {
|
||||
await applicantUser
|
||||
.send({
|
||||
flags: Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
isAccept ? "Application Accepted" : "Application Rejected",
|
||||
isAccept
|
||||
? `Congratulations! Your ${application.typeName} application in **${interaction.guild.name}** has been accepted.`
|
||||
: `Your ${application.typeName} application in **${interaction.guild.name}** has been rejected.`,
|
||||
isAccept ? 0x57f287 : 0xed4245
|
||||
),
|
||||
],
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("[ApplicationSystem] Interaction error:", error);
|
||||
|
||||
if (interaction.deferred || interaction.replied) {
|
||||
return;
|
||||
}
|
||||
|
||||
return interaction
|
||||
.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Error",
|
||||
"Something went wrong while handling that application interaction.",
|
||||
0xed4245
|
||||
),
|
||||
],
|
||||
})
|
||||
.catch(() => null);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = new Command({
|
||||
name: addonConfig.Command?.Name || "applicationsetup",
|
||||
description:
|
||||
addonConfig.Command?.Description || "Posts the application panel",
|
||||
options: [],
|
||||
permissions: addonConfig.Command?.Permission || ["ManageGuild"],
|
||||
|
||||
async run(interaction) {
|
||||
registerAddonInteractions(interaction.client);
|
||||
|
||||
await interaction.channel.send({
|
||||
flags: Discord.MessageFlags.IsComponentsV2,
|
||||
components: [buildPanelContainer()],
|
||||
});
|
||||
|
||||
return interaction.reply({
|
||||
flags:
|
||||
Discord.MessageFlags.Ephemeral |
|
||||
Discord.MessageFlags.IsComponentsV2,
|
||||
components: [
|
||||
buildNoticeContainer(
|
||||
"Application Panel Posted",
|
||||
"The application panel has been posted in this channel.",
|
||||
0x57f287
|
||||
),
|
||||
],
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,83 @@
|
||||
const Discord = require("discord.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const { Command } = require('../Structures/Addon.js'); // Adjust the path as needed
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
|
||||
module.exports = new Command({
|
||||
name: "calculator",
|
||||
description: "Use a calculator to solve your math problems.",
|
||||
options: [],
|
||||
permissions: ["SendMessages"],
|
||||
|
||||
async run(interaction) {
|
||||
let expression = "";
|
||||
let history = "";
|
||||
|
||||
const createButton = (label, customId) => {
|
||||
return new Discord.ButtonBuilder()
|
||||
.setLabel(label)
|
||||
.setCustomId(customId)
|
||||
.setStyle(Discord.ButtonStyle.Secondary);
|
||||
};
|
||||
|
||||
const createRow = (buttons) => {
|
||||
return new Discord.ActionRowBuilder().addComponents(buttons);
|
||||
};
|
||||
|
||||
const rows = [
|
||||
createRow([createButton('7', '7'), createButton('8', '8'), createButton('9', '9'), createButton('/', '/')]),
|
||||
createRow([createButton('4', '4'), createButton('5', '5'), createButton('6', '6'), createButton('*', '*')]),
|
||||
createRow([createButton('1', '1'), createButton('2', '2'), createButton('3', '3'), createButton('-', '-')]),
|
||||
createRow([createButton('0', '0'), createButton('.', '.'), createButton('=', '='), createButton('+', '+')]),
|
||||
createRow([createButton('Clear', 'clear')])
|
||||
];
|
||||
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setTitle('Calculator')
|
||||
.setDescription('Calculator')
|
||||
.setColor(supportbot.Embed.Colours.General);
|
||||
|
||||
const message = await interaction.reply({
|
||||
embeds: [embed],
|
||||
components: rows,
|
||||
fetchReply: true
|
||||
});
|
||||
|
||||
const filter = i => i.user.id === interaction.user.id;
|
||||
const collector = message.createMessageComponentCollector({ filter, time: 60000 });
|
||||
|
||||
collector.on('collect', async i => {
|
||||
if (i.customId === 'clear') {
|
||||
expression = "";
|
||||
history = "";
|
||||
} else if (i.customId === '=') {
|
||||
try {
|
||||
const result = eval(expression).toString();
|
||||
history += `${expression} = ${result}\n`;
|
||||
expression = result;
|
||||
} catch {
|
||||
expression = "Error";
|
||||
}
|
||||
} else {
|
||||
if (history.endsWith(`${expression} = ${expression}\n`)) {
|
||||
history += `\n`;
|
||||
expression = i.customId;
|
||||
} else {
|
||||
expression += i.customId;
|
||||
}
|
||||
}
|
||||
|
||||
await i.update({
|
||||
embeds: [new Discord.EmbedBuilder().setTitle('Calculator').setDescription(`\`\`\`${history}${expression}\`\`\``).setColor(supportbot.Embed.Colours.General)],
|
||||
components: rows
|
||||
});
|
||||
});
|
||||
|
||||
collector.on('end', collected => {
|
||||
message.edit({
|
||||
components: []
|
||||
});
|
||||
});
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,139 @@
|
||||
// SupportBot | Emerald Services
|
||||
// Discord Stats addon | Created by Xiled Emerald
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const Discord = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
|
||||
// If you need to read something from the main config!
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
|
||||
// This is for your Addon Configuration
|
||||
const config = yaml.load(fs.readFileSync('./Addons/Configs/discord-stats.yml', 'utf8'));
|
||||
|
||||
const { Command, Event } = require("../Structures/Addon");
|
||||
|
||||
module.exports.commands = [
|
||||
|
||||
new Command({
|
||||
name: "stats",
|
||||
description: "Create the discord statistic channels",
|
||||
options: [],
|
||||
permissions: ["SEND_MESSAGES"],
|
||||
async run(interaction) {
|
||||
const guild = interaction.guild;
|
||||
const statsCategory = guild.channels.cache.find(
|
||||
(category) =>
|
||||
category.name === config.StatsCategory ||
|
||||
category.id === config.StatsCategory
|
||||
);
|
||||
|
||||
if (!statsCategory) {
|
||||
const errorEmbed = new Discord.MessageEmbed()
|
||||
.setTitle("Invalid Category")
|
||||
.setDescription(
|
||||
`${supportbot.InvalidChannel}\n\n**Category Required:** \`${config.StatsCategory}\``
|
||||
)
|
||||
.setColor(supportbot.ErrorColour);
|
||||
|
||||
interaction.reply({
|
||||
embeds: [errorEmbed],
|
||||
ephemeral: true,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const guildName = guild.name;
|
||||
const userCount = guild.memberCount;
|
||||
const roleCount = guild.roles.cache.size;
|
||||
const channelCount = guild.channels.cache.size;
|
||||
|
||||
try {
|
||||
const guildNameChannel = await guild.channels.create(guildName, {
|
||||
type: "GUILD_VOICE",
|
||||
});
|
||||
|
||||
const userCountChannel = await guild.channels.create(
|
||||
`${config.UserCount_Name} ${userCount}`,
|
||||
{
|
||||
type: "GUILD_VOICE",
|
||||
}
|
||||
);
|
||||
|
||||
const channelCountChannel = await guild.channels.create(
|
||||
`${config.ChannelCount_Name} ${channelCount}`,
|
||||
{
|
||||
type: "GUILD_VOICE",
|
||||
}
|
||||
);
|
||||
|
||||
const roleCountChannel = await guild.channels.create(
|
||||
`${config.RoleCount_Name} ${roleCount}`,
|
||||
{
|
||||
type: "GUILD_VOICE",
|
||||
}
|
||||
);
|
||||
|
||||
guildNameChannel.permissionOverwrites.edit(guild.id, {
|
||||
VIEW_CHANNEL: true,
|
||||
CONNECT: false,
|
||||
});
|
||||
|
||||
userCountChannel.permissionOverwrites.edit(guild.id, {
|
||||
VIEW_CHANNEL: true,
|
||||
CONNECT: false,
|
||||
});
|
||||
|
||||
channelCountChannel.permissionOverwrites.edit(guild.id, {
|
||||
VIEW_CHANNEL: true,
|
||||
CONNECT: false,
|
||||
});
|
||||
|
||||
roleCountChannel.permissionOverwrites.edit(guild.id, {
|
||||
VIEW_CHANNEL: true,
|
||||
CONNECT: false,
|
||||
});
|
||||
|
||||
userCountChannel.setParent(statsCategory.id);
|
||||
roleCountChannel.setParent(statsCategory.id);
|
||||
guildNameChannel.setParent(statsCategory.id);
|
||||
channelCountChannel.setParent(statsCategory.id);
|
||||
|
||||
const successEmbed = new Discord.MessageEmbed()
|
||||
.setDescription(":white_check_mark: **[DISCORD STATS]** Channels created successfully!")
|
||||
.setColor(supportbot.EmbedColour);
|
||||
|
||||
interaction.reply({
|
||||
embeds: [successEmbed],
|
||||
ephemeral: true,
|
||||
});
|
||||
|
||||
setInterval(() => {
|
||||
userCountChannel.setName(
|
||||
`${config.UserCount_Name} ${userCount}`
|
||||
);
|
||||
channelCountChannel.setName(
|
||||
`${config.ChannelCount_Name} ${channelCount}`
|
||||
);
|
||||
roleCountChannel.setName(
|
||||
`${config.RoleCount_Name} ${roleCount}`
|
||||
);
|
||||
}, 30000);
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
const errorEmbed = new Discord.MessageEmbed()
|
||||
.setTitle("Error")
|
||||
.setDescription("An error occurred while creating the stats channels.")
|
||||
.setColor(supportbot.ErrorColour);
|
||||
|
||||
interaction.reply({
|
||||
embeds: [errorEmbed],
|
||||
ephemeral: true,
|
||||
});
|
||||
}
|
||||
},
|
||||
}),
|
||||
|
||||
];
|
||||
@@ -0,0 +1,292 @@
|
||||
const Discord = require("discord.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const fetch = require("node-fetch");
|
||||
const { Command } = require('../Structures/Addon.js');
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const vouchleyaddon = yaml.load(fs.readFileSync("./Addons/Configs/vouchley.yml", "utf8"));
|
||||
|
||||
function formatURL(url) {
|
||||
if (!url) return 'https://www.vouchley.com';
|
||||
if (!url.startsWith('http://') && !url.startsWith('https://')) {
|
||||
return `https://${url}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function formatDate(timestamp) {
|
||||
return `<t:${Math.floor(new Date(timestamp).getTime() / 1000)}:D>`;
|
||||
}
|
||||
|
||||
function createReviewEmbed(review, username, currentPage, totalReviews) {
|
||||
const embed = new Discord.EmbedBuilder()
|
||||
.setTitle(`Review for ${username}`)
|
||||
.setColor(vouchleyaddon.Embed.Colour);
|
||||
|
||||
const fields = [
|
||||
{
|
||||
name: "Rating",
|
||||
value: "⭐".repeat(Math.min(Math.max(review.rating, 1), 5)) || "No rating",
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "Platform",
|
||||
value: review.platform?.toString() || "Not specified",
|
||||
inline: true
|
||||
},
|
||||
{
|
||||
name: "Date",
|
||||
value: formatDate(review.time_sent),
|
||||
inline: true
|
||||
}
|
||||
];
|
||||
|
||||
if (review.message && review.message.trim()) {
|
||||
fields.push({
|
||||
name: "Review",
|
||||
value: `\`\`\`${review.message.substring(0, 1024)}\`\`\``,
|
||||
inline: false
|
||||
});
|
||||
}
|
||||
|
||||
embed.addFields(fields);
|
||||
embed.setFooter({ text: `Review ${currentPage + 1} of ${totalReviews}` });
|
||||
|
||||
return embed;
|
||||
}
|
||||
|
||||
function createReviewNavigation(currentPage, totalReviews) {
|
||||
return new Discord.ActionRowBuilder()
|
||||
.addComponents(
|
||||
new Discord.ButtonBuilder()
|
||||
.setCustomId('prev_review')
|
||||
.setLabel('Previous')
|
||||
.setStyle(Discord.ButtonStyle.Primary)
|
||||
.setDisabled(currentPage === 0),
|
||||
new Discord.ButtonBuilder()
|
||||
.setCustomId('next_review')
|
||||
.setLabel('Next')
|
||||
.setStyle(Discord.ButtonStyle.Primary)
|
||||
.setDisabled(currentPage === totalReviews - 1),
|
||||
new Discord.ButtonBuilder()
|
||||
.setCustomId('back_to_profile')
|
||||
.setLabel('Back to Profile')
|
||||
.setStyle(Discord.ButtonStyle.Secondary)
|
||||
);
|
||||
}
|
||||
|
||||
const activeReviews = new Map();
|
||||
|
||||
module.exports = new Command({
|
||||
name: vouchleyaddon.Command.Name,
|
||||
description: vouchleyaddon.Command.Description,
|
||||
options: [
|
||||
{
|
||||
name: "search_type",
|
||||
description: "How do you want to search for the user?",
|
||||
type: 3,
|
||||
required: true,
|
||||
choices: [
|
||||
{ name: "Vouchley ID", value: "id" },
|
||||
{ name: "Vouchley Username", value: "username" },
|
||||
]
|
||||
},
|
||||
{
|
||||
name: "query",
|
||||
description: "Enter the ID, username, or Discord ID to search",
|
||||
type: 3,
|
||||
required: true
|
||||
}
|
||||
],
|
||||
permissions: vouchleyaddon.Command.Permissions,
|
||||
|
||||
async run(interaction) {
|
||||
const searchType = interaction.options.getString("search_type");
|
||||
const query = interaction.options.getString("query");
|
||||
|
||||
try {
|
||||
let apiUrl;
|
||||
switch (searchType) {
|
||||
case "id":
|
||||
apiUrl = `https://www.vouchley.com/api/v1/user?id=${query}`;
|
||||
break;
|
||||
case "username":
|
||||
apiUrl = `https://www.vouchley.com/api/v1/user?username=${query}`;
|
||||
break;
|
||||
}
|
||||
|
||||
const response = await fetch(apiUrl, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${vouchleyaddon.Vouchley.API_Key}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok || (data.error && data.error.code !== 200)) {
|
||||
return interaction.reply({
|
||||
content: `Error: ${data.error?.message || 'Failed to fetch user data'}`,
|
||||
ephemeral: vouchleyaddon.Embed.Ephemeral
|
||||
});
|
||||
}
|
||||
|
||||
const user = data.user;
|
||||
|
||||
const profileEmbed = new Discord.EmbedBuilder()
|
||||
.setTitle(`${user.username} - ${String(user.title || "N/A")}`)
|
||||
.setColor(vouchleyaddon.Embed.Colour)
|
||||
.setThumbnail(user.avatar_url)
|
||||
.addFields([
|
||||
{
|
||||
name: "Discord",
|
||||
value: `<@${String(user.discord_id)}> (\`${String(user.discord_id)}\`)`,
|
||||
inline: false
|
||||
},
|
||||
{
|
||||
name: "Average Rating",
|
||||
value: `${String(user.average_rating)} (\`${String(user.reviews.length)} Reviews\`)`,
|
||||
inline: false
|
||||
}
|
||||
]);
|
||||
|
||||
const profileButtons = [
|
||||
new Discord.ButtonBuilder()
|
||||
.setLabel(`Review ${user.username}`)
|
||||
.setURL(`https://www.vouchley.com/review?user=${user.username}`)
|
||||
.setStyle(Discord.ButtonStyle.Link)
|
||||
];
|
||||
|
||||
if (user.website) {
|
||||
profileButtons.push(
|
||||
new Discord.ButtonBuilder()
|
||||
.setLabel('Visit Website')
|
||||
.setURL(formatURL(user.website))
|
||||
.setStyle(Discord.ButtonStyle.Link)
|
||||
);
|
||||
}
|
||||
|
||||
if (user.reviews && user.reviews.length > 0) {
|
||||
profileButtons.push(
|
||||
new Discord.ButtonBuilder()
|
||||
.setCustomId('seereviews')
|
||||
.setLabel('See Reviews')
|
||||
.setEmoji("⭐")
|
||||
.setStyle(Discord.ButtonStyle.Secondary)
|
||||
);
|
||||
}
|
||||
|
||||
const profileRow = new Discord.ActionRowBuilder()
|
||||
.addComponents(profileButtons);
|
||||
|
||||
const message = await interaction.reply({
|
||||
embeds: [profileEmbed],
|
||||
components: [profileRow],
|
||||
fetchReply: true,
|
||||
ephemeral: vouchleyaddon.Embed.Ephemeral
|
||||
});
|
||||
|
||||
if (user.reviews && user.reviews.length > 0) {
|
||||
activeReviews.set(message.id, {
|
||||
reviews: user.reviews,
|
||||
currentPage: 0,
|
||||
username: user.username,
|
||||
profileEmbed,
|
||||
profileRow
|
||||
});
|
||||
|
||||
const collector = message.createMessageComponentCollector({
|
||||
time: 600000
|
||||
});
|
||||
|
||||
collector.on('collect', async i => {
|
||||
const reviewData = activeReviews.get(message.id);
|
||||
if (!reviewData) return;
|
||||
|
||||
switch (i.customId) {
|
||||
case 'seereviews': {
|
||||
const review = reviewData.reviews[reviewData.currentPage];
|
||||
const reviewEmbed = createReviewEmbed(
|
||||
review,
|
||||
reviewData.username,
|
||||
reviewData.currentPage,
|
||||
reviewData.reviews.length
|
||||
);
|
||||
const reviewNav = createReviewNavigation(
|
||||
reviewData.currentPage,
|
||||
reviewData.reviews.length
|
||||
);
|
||||
await i.update({
|
||||
embeds: [reviewEmbed],
|
||||
components: [reviewNav],
|
||||
ephemeral: vouchleyaddon.Embed.Ephemeral
|
||||
});
|
||||
break;
|
||||
}
|
||||
case 'prev_review': {
|
||||
if (reviewData.currentPage > 0) {
|
||||
reviewData.currentPage--;
|
||||
const review = reviewData.reviews[reviewData.currentPage];
|
||||
const reviewEmbed = createReviewEmbed(
|
||||
review,
|
||||
reviewData.username,
|
||||
reviewData.currentPage,
|
||||
reviewData.reviews.length
|
||||
);
|
||||
const reviewNav = createReviewNavigation(
|
||||
reviewData.currentPage,
|
||||
reviewData.reviews.length
|
||||
);
|
||||
await i.update({
|
||||
embeds: [reviewEmbed],
|
||||
components: [reviewNav],
|
||||
ephemeral: vouchleyaddon.Embed.Ephemeral
|
||||
});
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'next_review': {
|
||||
if (reviewData.currentPage < reviewData.reviews.length - 1) {
|
||||
reviewData.currentPage++;
|
||||
const review = reviewData.reviews[reviewData.currentPage];
|
||||
const reviewEmbed = createReviewEmbed(
|
||||
review,
|
||||
reviewData.username,
|
||||
reviewData.currentPage,
|
||||
reviewData.reviews.length
|
||||
);
|
||||
const reviewNav = createReviewNavigation(
|
||||
reviewData.currentPage,
|
||||
reviewData.reviews.length
|
||||
);
|
||||
await i.update({ embeds: [reviewEmbed], components: [reviewNav] });
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'back_to_profile': {
|
||||
await i.update({
|
||||
embeds: [reviewData.profileEmbed],
|
||||
components: [reviewData.profileRow]
|
||||
});
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
collector.on('end', () => {
|
||||
activeReviews.delete(message.id);
|
||||
});
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error(error);
|
||||
if (!interaction.replied) {
|
||||
await interaction.reply({
|
||||
content: "There was an error fetching Vouchley user data.",
|
||||
ephemeral: vouchleyaddon.Embed.Ephemeral
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -5,9 +5,9 @@ const fs = require("fs");
|
||||
const { ApplicationCommandOptionType, ApplicationCommandType, EmbedBuilder, MessageFlags } = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const supportbotai = yaml.load(fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const supportbotai = require("../../Structures/ConfigStore").supportbotAi;
|
||||
|
||||
const Command = require("../../Structures/Command.js");
|
||||
const AIDatabase = require("../../Structures/AIDatabase.js");
|
||||
|
||||
@@ -2,8 +2,8 @@ const fs = require("fs");
|
||||
const Discord = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
|
||||
const Command = require("../../Structures/Command.js");
|
||||
|
||||
@@ -54,7 +54,7 @@ module.exports = new Command({
|
||||
iconURL: interaction.user.displayAvatarURL(),
|
||||
});
|
||||
|
||||
interaction.reply({
|
||||
await interaction.reply({
|
||||
flags: Discord.MessageFlags.Ephemeral,
|
||||
embeds: [HelpEmbed1],
|
||||
});
|
||||
|
||||
@@ -1,22 +1,11 @@
|
||||
// SupportBot | Emerald Services
|
||||
// Info Command
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const Discord = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const supportbot = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
||||
);
|
||||
|
||||
const cmdconfig = yaml.load(
|
||||
fs.readFileSync("./Configs/commands.yml", "utf8")
|
||||
);
|
||||
|
||||
const msgconfig = yaml.load(
|
||||
fs.readFileSync("./Configs/messages.yml", "utf8")
|
||||
);
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
|
||||
const Command = require("../../Structures/Command.js");
|
||||
|
||||
|
||||
@@ -5,10 +5,8 @@ const fs = require("fs");
|
||||
|
||||
const Discord = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
const supportbot = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
||||
);
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
|
||||
const Command = require("../../Structures/Command.js");
|
||||
|
||||
@@ -19,15 +17,13 @@ module.exports = new Command({
|
||||
permissions: cmdconfig.Ping.Permission,
|
||||
|
||||
async run(interaction) {
|
||||
let disableCommand = true;
|
||||
|
||||
const PingEmbed = new Discord.EmbedBuilder()
|
||||
.setDescription(
|
||||
`:ping_pong: **Ping:** \`${interaction.client.ws.ping} ms\``
|
||||
`:ping_pong: **Ping:** \`${interaction.client.ws.ping} ms\``,
|
||||
)
|
||||
.setColor(supportbot.Embed.Colours.General);
|
||||
|
||||
interaction.reply({
|
||||
await interaction.reply({
|
||||
embeds: [PingEmbed],
|
||||
});
|
||||
},
|
||||
|
||||
@@ -20,9 +20,9 @@ const yaml = require("js-yaml");
|
||||
const db = require("../../Structures/Database.js");
|
||||
const Command = require("../../Structures/Command.js");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
|
||||
const clockedInUsers = new Set();
|
||||
|
||||
|
||||
@@ -7,9 +7,9 @@ const yaml = require("js-yaml");
|
||||
|
||||
const { translate } = require("@vitalets/google-translate-api");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
|
||||
const Command = require("../../Structures/Command.js");
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@ const Discord = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
const path = require("path");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
|
||||
const Command = require("../../Structures/Command.js");
|
||||
const DB = require("../../Structures/Database.js");
|
||||
|
||||
@@ -2,9 +2,9 @@ const Discord = require("discord.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const SuggestionDB = require("../../Structures/Database.js");
|
||||
const Command = require("../../Structures/Command.js");
|
||||
|
||||
|
||||
@@ -8,9 +8,9 @@ const db = require("../../Structures/Database.js");
|
||||
const TicketManager = require("../../Structures/TicketManager.js");
|
||||
const Command = require("../../Structures/Command.js");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
|
||||
function getDepartmentConfig(departmentKey) {
|
||||
return supportbot.Ticket?.DepartmentSystem?.Departments?.[departmentKey] || null;
|
||||
|
||||
@@ -12,9 +12,9 @@ const {
|
||||
TextInputStyle,
|
||||
} = require("discord.js");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
|
||||
const TicketManager = require("../../Structures/TicketManager.js");
|
||||
const Command = require("../../Structures/Command.js");
|
||||
|
||||
@@ -19,14 +19,10 @@ const yaml = require("js-yaml");
|
||||
|
||||
const db = require("../../Structures/Database.js");
|
||||
|
||||
const panelconfig = yaml.load(
|
||||
fs.readFileSync("./Configs/ticket-panel.yml", "utf8"),
|
||||
);
|
||||
const supportbot = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot.yml", "utf8"),
|
||||
);
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const panelconfig = require("../../Structures/ConfigStore").ticketPanel;
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
|
||||
const Command = require("../../Structures/Command.js");
|
||||
const TicketNumberID = require("../../Structures/TicketID.js");
|
||||
|
||||
@@ -19,9 +19,9 @@ const {
|
||||
} = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const panelconfig = yaml.load(fs.readFileSync("./Configs/ticket-panel.yml", "utf8"));
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const panelconfig = require("../../Structures/ConfigStore").ticketPanel;
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
|
||||
const Command = require("../../Structures/Command.js");
|
||||
const db = require("../../Structures/Database.js");
|
||||
|
||||
@@ -16,9 +16,9 @@ const Command = require('../../Structures/Command.js');
|
||||
const fs = require('fs');
|
||||
const yaml = require('js-yaml');
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync('./Configs/supportbot.yml', 'utf8'));
|
||||
const cmdconfig = yaml.load(fs.readFileSync('./Configs/commands.yml', 'utf8'));
|
||||
const msgconfig = yaml.load(fs.readFileSync('./Configs/messages.yml', 'utf8'));
|
||||
const supportbot = require('../../Structures/ConfigStore').supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
|
||||
function parseColor(color) {
|
||||
if (!color) return 0x5865F2;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
const fs = require("fs");
|
||||
const { ApplicationCommandOptionType, ApplicationCommandType, EmbedBuilder, ActionRowBuilder, ButtonBuilder, ButtonStyle, ComponentType, MessageFlags } = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
const msgconfig = require("../../Structures/ConfigStore").messages;
|
||||
|
||||
const Command = require("../../Structures/Command.js");
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const db = require("../../Structures/Database.js");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const supportbot = require("../../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../../Structures/ConfigStore").commands;
|
||||
|
||||
async function loadSettings() {
|
||||
return new Promise((resolve, reject) => {
|
||||
|
||||
+28
-2
@@ -1,8 +1,34 @@
|
||||
# -------------------------------
|
||||
# API Server Configuration
|
||||
# -------------------------------
|
||||
# Copy to api.yml and fill in your values (api.yml is not committed with real secrets).
|
||||
|
||||
API:
|
||||
Enabled: true
|
||||
Port: 3000 # PORT NUMBER FOR YOUR BOT
|
||||
SecretKey: "PUT_RANDOM_STRING_HERE" # DO NOT SHARE THIS KEY WITH ANYONE
|
||||
Port: 3000
|
||||
|
||||
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"
|
||||
|
||||
OAuth:
|
||||
Enabled: true
|
||||
ClientId: "1530910674551574580"
|
||||
ClientSecret: "GIWdonM3bdFILhkg_1XWWeVQRlGf_npu"
|
||||
# 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_ID"
|
||||
AllowedUserIds: []
|
||||
@@ -38,7 +38,7 @@ Welcome:
|
||||
Embed:
|
||||
Colour: "#45b463"
|
||||
Title: "Welcome to ServerName"
|
||||
Message: ":smile: %joined_user% has just landed!\n\n🔨 **Server Rules:** <#1202667199853232129>"
|
||||
Message: ":smile: %joined_user% has just landed!\n\n🔨 **Server Rules:** <#CHANNEL_ID>"
|
||||
Icon:
|
||||
Mode: "USER" # USER | BOT | CUSTOM
|
||||
CustomURL: "https://example.com/custom.png"
|
||||
@@ -64,7 +64,7 @@ Welcome:
|
||||
Modern:
|
||||
Colour: "#45b463"
|
||||
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:
|
||||
Mode: "BOT" # USER | BOT | CUSTOM
|
||||
CustomURL: "https://example.com/custom-icon.png"
|
||||
|
||||
@@ -35,7 +35,7 @@ Features:
|
||||
SuggestTickets: true # Should SupportBot AI suggest creating a ticket if the user needs more help?
|
||||
|
||||
Channels:
|
||||
AIChannel: "1469028791312715902"
|
||||
AIChannel: "CHANNEL_ID"
|
||||
|
||||
Embed:
|
||||
Color: "#45b463"
|
||||
|
||||
+44
-47
@@ -12,7 +12,7 @@
|
||||
#
|
||||
# SupportBot Configuration (supportbot.yml)
|
||||
|
||||
SupportBot_Version: "v26.1 DEV-2"
|
||||
SupportBot_Version: "v26.2-pre1"
|
||||
|
||||
# -------------------------
|
||||
# General
|
||||
@@ -21,19 +21,18 @@ SupportBot_Version: "v26.1 DEV-2"
|
||||
General:
|
||||
Name: "SupportBot"
|
||||
Token: "BOT_TOKEN"
|
||||
|
||||
Addons:
|
||||
Enabled: true
|
||||
|
||||
GuildId: "GUILD_ID"
|
||||
|
||||
# -------------------------
|
||||
# Activity
|
||||
# -------------------------
|
||||
|
||||
Activity:
|
||||
Status: "SupportBot v8"
|
||||
Type: "Competing"
|
||||
Status: "SupportBot"
|
||||
Type: "Watching"
|
||||
StreamingURL: "https://www.twitch.tv/YOUR_TWITCH_NAME"
|
||||
|
||||
# Available Types:
|
||||
# Playing
|
||||
# Watching
|
||||
@@ -43,9 +42,9 @@ Activity:
|
||||
|
||||
# You can also update the activity via Discord using: /settings
|
||||
|
||||
# -------------------------
|
||||
# Embeds
|
||||
# -------------------------
|
||||
# -------------------------
|
||||
# Embeds
|
||||
# -------------------------
|
||||
|
||||
Embed:
|
||||
Colours:
|
||||
@@ -55,12 +54,11 @@ Embed:
|
||||
Warn: "#fca117"
|
||||
|
||||
Footer: "SupportBot | Developed by Emerald Development"
|
||||
|
||||
# Use 0x<hexcode> to change the colour of the embeds.
|
||||
|
||||
# -------------------------
|
||||
# Roles
|
||||
# -------------------------
|
||||
# -------------------------
|
||||
# Roles
|
||||
# -------------------------
|
||||
|
||||
Roles:
|
||||
AutoRole:
|
||||
@@ -74,19 +72,18 @@ Roles:
|
||||
|
||||
Mod:
|
||||
AllowSupportStaff: false
|
||||
|
||||
# Allow Support Staff to use the /mod command. (More in detail customisation in commands.yml)
|
||||
|
||||
# ----------------------------
|
||||
# System Messages
|
||||
# ----------------------------
|
||||
# ----------------------------
|
||||
# System Messages
|
||||
# ----------------------------
|
||||
|
||||
Welcome:
|
||||
Enabled: true
|
||||
Welcome:
|
||||
Enabled: false
|
||||
Channel: "CHANNEL_ID"
|
||||
|
||||
Leave:
|
||||
Enabled: true
|
||||
Leave:
|
||||
Enabled: false
|
||||
Channel: "CHANNEL_ID"
|
||||
|
||||
# ----------------------------
|
||||
@@ -110,7 +107,7 @@ Suggestions:
|
||||
# If true this will allow the user to vote for a suggestion they created
|
||||
|
||||
ShowUsers: true
|
||||
|
||||
|
||||
Threads:
|
||||
Enabled: true
|
||||
Title: "Suggestion-releated Thread"
|
||||
@@ -121,7 +118,6 @@ Suggestions:
|
||||
Downvote: "2"
|
||||
RemoveVote: "4"
|
||||
RemoveVote_Title: "Remove Vote"
|
||||
|
||||
# Available Button Styles
|
||||
# 1 - Blurple
|
||||
# 2 - Grey
|
||||
@@ -129,21 +125,21 @@ Suggestions:
|
||||
# 4 - Red
|
||||
|
||||
Tags:
|
||||
Created: "TAG_ID"
|
||||
Considering: "TAG_ID"
|
||||
Solved: "TAG_ID"
|
||||
Denied: "TAG_ID"
|
||||
Created: "TAG_ID"
|
||||
Considering: "TAG_ID"
|
||||
Solved: "TAG_ID"
|
||||
Denied: "TAG_ID"
|
||||
|
||||
# -------------------------
|
||||
# Tickets
|
||||
# -------------------------
|
||||
|
||||
VoiceTickets:
|
||||
Name: "%username%'s VC"
|
||||
Name: "%username%'s VC"
|
||||
Category: "CATEGORY_ID"
|
||||
|
||||
Ticket:
|
||||
TicketType: "channels"
|
||||
TicketType: "channels"
|
||||
|
||||
# Available TicketTypes:
|
||||
# channels - Tickets will be created as channels in the TicketChannelsCategory
|
||||
@@ -154,14 +150,14 @@ 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
|
||||
|
||||
TicketHome: "1469028381638000725"
|
||||
TicketHome: "CHANNEL_ID"
|
||||
|
||||
# What is TicketHome?
|
||||
# - Channel where the Ticket creation panel is sent.
|
||||
# - Channel where ticket threads are made. (only if ticket threads is enabled above.)
|
||||
|
||||
# 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.
|
||||
TicketArchiveCategory: "CATEGORY_ID"
|
||||
# ---
|
||||
@@ -184,14 +180,15 @@ Ticket:
|
||||
StaffOnly: true
|
||||
# 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
|
||||
|
||||
ClaimTickets:
|
||||
Enabled: false
|
||||
Channel: "CHANNEL_ID"
|
||||
Channel: "CHANNEL_ID"
|
||||
# Channel where the ticket assignments are sent for staff to claim.
|
||||
|
||||
|
||||
ClaimAcceptTime: 120000
|
||||
# How long the assigned user has to accept the ticket before it gets re-assigned to someone else.
|
||||
# 120000 = 2 Minutes
|
||||
@@ -203,7 +200,6 @@ Ticket:
|
||||
ButtonTitle: "Claim Ticket"
|
||||
ButtonEmoji: "🎫"
|
||||
Button: "1"
|
||||
|
||||
# Available Button Styles
|
||||
# 1 - Blurple
|
||||
# 2 - Grey
|
||||
@@ -211,12 +207,13 @@ Ticket:
|
||||
# 4 - Red
|
||||
|
||||
ReviewSystem:
|
||||
Enabled: false
|
||||
Enabled: false
|
||||
Channel: "CHANNEL_ID"
|
||||
UseModal: true
|
||||
|
||||
Questions: # Default questions asked when a ticket is created, these are used if departments are disabled.
|
||||
Enabled: false
|
||||
Questions:
|
||||
# Default questions asked when a ticket is created, these are used if departments are disabled.
|
||||
Enabled: false
|
||||
List:
|
||||
- "Please provide your account email?"
|
||||
- "What is the nature of your issue?"
|
||||
@@ -228,16 +225,16 @@ Ticket:
|
||||
TicketBlacklistLog: "CHANNEL_ID"
|
||||
|
||||
AIMode:
|
||||
Enabled: true
|
||||
Enabled: false
|
||||
ThreadName: "Chat with SupportBot AI"
|
||||
WelcomeMessage: "Hi! I’m SupportBot AI. You can chat with me here about your issue."
|
||||
|
||||
# -------------------------
|
||||
# Ticket Departments
|
||||
# -------------------------
|
||||
# -------------------------
|
||||
# Ticket Departments
|
||||
# -------------------------
|
||||
|
||||
DepartmentSystem:
|
||||
Enabled: true
|
||||
Enabled: false
|
||||
Placeholder: "Choose a ticket department"
|
||||
DefaultDepartment: "general"
|
||||
|
||||
@@ -339,14 +336,14 @@ Buttons:
|
||||
# 3 - Green
|
||||
# 4 - Red
|
||||
|
||||
# -------------------------
|
||||
# Select Menus
|
||||
# -------------------------
|
||||
# -------------------------
|
||||
# Select Menus
|
||||
# -------------------------
|
||||
|
||||
SelectMenus:
|
||||
Tickets:
|
||||
PanelEmoji: "⚙️"
|
||||
|
||||
|
||||
CloseEmoji: "🗑️"
|
||||
ArchiveEmoji: "♻️"
|
||||
LockEmoji: "🔐"
|
||||
|
||||
@@ -21,7 +21,8 @@ Panel: true
|
||||
|
||||
Style:
|
||||
Title: "## SupportBot Ticket Creation"
|
||||
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: "Click on the button to file a `Ticket` and our team will try their
|
||||
best to assist you in the best way we can!"
|
||||
Image: "https://i.imgur.com/dejQvkg.png"
|
||||
Color: "#45b463"
|
||||
Layout: "4"
|
||||
@@ -45,7 +46,7 @@ Button:
|
||||
# 4 - Green
|
||||
|
||||
DepartmentMenu:
|
||||
CustomId: "ticketdepartmentselect"
|
||||
CustomId: "ticketdepartmentselect"
|
||||
Placeholder: "Choose a ticket department"
|
||||
DefaultDescription: "Open a support ticket"
|
||||
DefaultEmoji: "🎫"
|
||||
@@ -58,7 +59,8 @@ Settings:
|
||||
BottomDivider: false
|
||||
|
||||
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."
|
||||
PanelSent: "Ticket panel message 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 axios = require("axios");
|
||||
|
||||
const supportbotai = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"),
|
||||
);
|
||||
const supportbotai = require("../../Structures/ConfigStore").supportbotAi;
|
||||
|
||||
function extractTextFromMessage(message) {
|
||||
if (!message) return "";
|
||||
|
||||
@@ -5,9 +5,7 @@ const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const OpenAI = require("openai");
|
||||
|
||||
const supportbotai = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"),
|
||||
);
|
||||
const supportbotai = require("../../Structures/ConfigStore").supportbotAi;
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: supportbotai.General.Model_API_Key,
|
||||
|
||||
@@ -5,9 +5,7 @@ const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const OpenAI = require("openai");
|
||||
|
||||
const supportbotai = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"),
|
||||
);
|
||||
const supportbotai = require("../../Structures/ConfigStore").supportbotAi;
|
||||
|
||||
const client = new OpenAI({
|
||||
apiKey: supportbotai.General.Model_API_Key,
|
||||
|
||||
+28
-6
@@ -1,9 +1,31 @@
|
||||
const Event = require("../Structures/Event.js");
|
||||
module.exports = new Event("guildCreate", async (client) => {
|
||||
console.log(
|
||||
`\u001b[31m`,
|
||||
`${client.user.username} is not in the correct server set in your config. Please leave all other servers and restart the bot.`
|
||||
const {
|
||||
getConfiguredGuildId,
|
||||
syncGuildHealth,
|
||||
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 {
|
||||
getConfiguredGuildId,
|
||||
syncGuildHealth,
|
||||
logGuildWarning,
|
||||
} = require("../Structures/GuildManager.js");
|
||||
|
||||
module.exports = new Event("guildDelete", async (client) => {
|
||||
console.log(
|
||||
`\u001b[31m`,
|
||||
`${client.user.username} is not in the correct server set in your config. Please join the server and restart the bot.`
|
||||
);
|
||||
console.log(`\u001b[31m`, `${client.user.username} will now exit.`);
|
||||
return process.exit(1);
|
||||
module.exports = new Event("guildDelete", async (client, guild) => {
|
||||
const configuredId = getConfiguredGuildId();
|
||||
|
||||
if (configuredId && guild.id === configuredId) {
|
||||
logGuildWarning(
|
||||
client,
|
||||
`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,
|
||||
SectionBuilder,
|
||||
ThumbnailBuilder,
|
||||
AttachmentBuilder
|
||||
MediaGalleryBuilder
|
||||
} = require("discord.js");
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const Event = require("../Structures/Event.js");
|
||||
|
||||
const supportbot = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
||||
);
|
||||
const msgconfig = yaml.load(
|
||||
fs.readFileSync("./Configs/messages.yml", "utf8")
|
||||
);
|
||||
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||
|
||||
function getWelcomeThumbnail(client, member, iconCfg) {
|
||||
switch ((iconCfg?.Mode || "").toUpperCase()) {
|
||||
@@ -35,12 +29,12 @@ function getWelcomeThumbnail(client, member, iconCfg) {
|
||||
}
|
||||
|
||||
function buildModernComponents(client, member) {
|
||||
const container = new ContainerBuilder()
|
||||
const container = new ContainerBuilder();
|
||||
|
||||
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(
|
||||
new TextDisplayBuilder().setContent(
|
||||
msgconfig.Welcome.Modern.Title.replace(/%joined_user%/g, member.user)
|
||||
@@ -60,11 +54,16 @@ function buildModernComponents(client, member) {
|
||||
|
||||
container.addSectionComponents(section);
|
||||
|
||||
// FIXED: Using MediaGalleryBuilder instead of TextDisplayBuilder for images
|
||||
if (msgconfig.Welcome.Modern.Image?.Enabled && msgconfig.Welcome.Modern.Image.URL) {
|
||||
container.addTextDisplayComponents(
|
||||
new TextDisplayBuilder().setContent(
|
||||
``
|
||||
)
|
||||
container.addMediaGalleryComponents(
|
||||
new MediaGalleryBuilder().addItems([
|
||||
{
|
||||
media: {
|
||||
url: msgconfig.Welcome.Modern.Image.URL
|
||||
}
|
||||
}
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
@@ -81,7 +80,6 @@ function buildModernComponents(client, member) {
|
||||
});
|
||||
|
||||
container.addActionRowComponents(row);
|
||||
|
||||
}
|
||||
|
||||
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!`);
|
||||
|
||||
});
|
||||
@@ -3,8 +3,8 @@ const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const Event = require("../Structures/Event.js");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||
|
||||
module.exports = new Event("guildMemberRemove", async (client, member) => {
|
||||
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
const fs = require("fs");
|
||||
const Discord = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const supportbot = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot.yml", "utf8"),
|
||||
);
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||
const cmdconfig = require("../Structures/ConfigStore").commands;
|
||||
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||
|
||||
const Event = require("../Structures/Event.js");
|
||||
const db = require("../Structures/Database.js");
|
||||
@@ -69,6 +65,7 @@ module.exports = new Event("interactionCreate", async (client, interaction) => {
|
||||
"\u001b[37;1m",
|
||||
`${interaction.user.username} has executed ${command.name}`,
|
||||
);
|
||||
|
||||
await command.run(interaction);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
const fs = require("fs");
|
||||
const Discord = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
const axios = require("axios");
|
||||
|
||||
const supportbotai = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"),
|
||||
);
|
||||
const supportbot = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot.yml", "utf8"),
|
||||
);
|
||||
const supportbotai = require("../Structures/ConfigStore").supportbotAi;
|
||||
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||
|
||||
const Event = require("../Structures/Event.js");
|
||||
const AIDatabase = require("../Structures/AIDatabase.js");
|
||||
|
||||
@@ -3,8 +3,8 @@ const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
const Event = require("../Structures/Event.js");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||
|
||||
module.exports = new Event("messageDelete", async (client, message) => {
|
||||
if (!message.guild) return;
|
||||
|
||||
@@ -1,13 +1,6 @@
|
||||
const fs = require("fs");
|
||||
const yaml = require("js-yaml");
|
||||
|
||||
const supportbot = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot.yml", "utf8"),
|
||||
);
|
||||
const supportbotai = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot-ai.yml", "utf8"),
|
||||
);
|
||||
const cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||
const supportbotai = require("../Structures/ConfigStore").supportbotAi;
|
||||
const cmdconfig = require("../Structures/ConfigStore").commands;
|
||||
|
||||
const Event = require("../Structures/Event.js");
|
||||
|
||||
|
||||
@@ -4,8 +4,8 @@ const yaml = require("js-yaml");
|
||||
const Event = require("../Structures/Event.js");
|
||||
|
||||
// Load the configuration files
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("../Structures/ConfigStore").supportbot;
|
||||
const msgconfig = require("../Structures/ConfigStore").messages;
|
||||
|
||||
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");
|
||||
|
||||
// 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("");
|
||||
};
|
||||
const { runBotReady } = require("../Structures/BotStartup.js");
|
||||
|
||||
module.exports = new Event("clientReady", async (client) => {
|
||||
setActivity(client, supportbot?.Activity?.Type, supportbot?.Activity?.Status);
|
||||
await logStartup(client);
|
||||
await checkConfigs(client);
|
||||
});
|
||||
// restartDiscordBot runs startup manually — clientReady often does not re-fire anyway
|
||||
if (client.__explicitStartup) return;
|
||||
await runBotReady(client, { clearConsole: true, logCommands: false });
|
||||
});
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
| SupportBot | Node.JS | Discord.JS | Supported |
|
||||
|------------|---------|------------|------------|
|
||||
| 26.x | 22.x | 14.x. | ✅ Yes
|
||||
| 8.x | 22.x | 14.x. | ✅ Yes
|
||||
| 7.7.3 | 16.x | 13.x. | ❌ No
|
||||
| 7.x | 16.x | 13.x. | ❌ No
|
||||
@@ -39,58 +40,183 @@
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://emeraldsrv.com/">Website</a>
|
||||
•
|
||||
<a href="https://github.com/Emerald-Services/SupportBot/wiki">Wiki</a>
|
||||
<br>
|
||||
<a href="#about">About</a>
|
||||
•
|
||||
<a href="#support">Support</a>
|
||||
•
|
||||
<a href="#license">License</a>
|
||||
•
|
||||
<a href="https://github.com/Emerald-Services/SupportBot/wiki/Third-Party-Developer">Third-Party</a>
|
||||
•
|
||||
<a href="#contributors">Contributors</a>
|
||||
<a href="https://opensource.org/licenses/MIT">
|
||||
<img src="https://img.shields.io/badge/license-MIT-green?style=for-the-badge" alt="MIT License">
|
||||
</a>
|
||||
<a href="https://github.com/discordjs/discord.js">
|
||||
<img src="https://img.shields.io/badge/discord.js-v14-blue?style=for-the-badge" alt="discord.js v14">
|
||||
</a>
|
||||
<a href="https://nodejs.org/">
|
||||
<img src="https://img.shields.io/badge/node.js-22.x-green?style=for-the-badge" alt="Node.js 22.x">
|
||||
</a>
|
||||
<a href="https://github.com/Emerald-Services/SupportBot">
|
||||
<img src="https://img.shields.io/badge/upstream-SupportBot-purple?style=for-the-badge" alt="Upstream SupportBot">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#about">About</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#requirements">Requirements</a> ·
|
||||
<a href="#getting-started">Getting started</a> ·
|
||||
<a href="#dashboard-access">Dashboard access</a> ·
|
||||
<a href="#configuration">Configuration</a> ·
|
||||
<a href="#addons">Addons</a> ·
|
||||
<a href="#development">Development</a> ·
|
||||
<a href="#upstream--credits">Upstream & credits</a> ·
|
||||
<a href="#license">License</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## About
|
||||
|
||||
SupportBot is an Open-Source discord bot built with [discord.js](https://github.com/discordjs/discord.js). It's primarily focused to help servers organise and manage their support system in a unique way with full customisation to match your brand.
|
||||
This repository is a **community fork** of [SupportBot](https://github.com/Emerald-Services/SupportBot) — the open-source Discord support/ticket bot by [Emerald Services](https://emeraldsrv.dev). It is **not** affiliated with or endorsed by Emerald Services.
|
||||
|
||||
## Support
|
||||
What this fork adds is a **self-hosted dashboard**: a modern web UI and REST API that run in the **same Node process** as the bot. You configure tickets, messages, commands, AI settings, addons, and more from the browser instead of editing YAML by hand. The API starts immediately on launch, so the dashboard stays reachable even while the bot is connecting or restarting.
|
||||
|
||||
<a href="https://emeraldsrv.com/discord">
|
||||
<img alt="Discord" src="https://img.shields.io/discord/597779408295821323?logo=discord&logoColor=white&style=for-the-badge">
|
||||
</a>
|
||||
If you only need the original bot without the dashboard, use the [official SupportBot repository](https://github.com/Emerald-Services/SupportBot).
|
||||
|
||||
## Features
|
||||
|
||||
### Self-hosted dashboard
|
||||
|
||||
| Area | What you get |
|
||||
|------|----------------|
|
||||
| **Overview** | Live stats — servers, members, open/closed tickets, module setup status, RAM/CPU/uptime |
|
||||
| **Visual config editors** | Edit `supportbot`, ticket panel, commands, messages, and AI config with form fields instead of raw YAML |
|
||||
| **Discord pickers** | Choose channels, roles, and categories from dropdowns when configuring the bot (no copy-pasting IDs) |
|
||||
| **Guild health** | Warnings if the bot is in the wrong server or extra guilds; quick link to fix `GuildId` or leave stray servers |
|
||||
| **Logs** | Browse Output, Warn, and Error logs from the dashboard |
|
||||
| **Transcripts** | View saved ticket HTML transcripts |
|
||||
| **Notifications** | In-app alerts for bot restarts, update checks, and installs |
|
||||
| **Settings** | Check for dashboard updates and apply them from the UI |
|
||||
| **Users & roles** | Invite staff via Discord OAuth; assign owner, admin, editor, viewer, or custom per-page permissions |
|
||||
| **Addons** | Browse and install addons from the [official Addons repo](https://github.com/Emerald-Services/Addons); edit addon configs in the sidebar like other config files |
|
||||
|
||||
### Bot & API (unchanged upstream behaviour, plus improvements)
|
||||
|
||||
- Full **SupportBot v26** ticket system, slash commands, AI assistant, and addon support
|
||||
- **Discord OAuth** login for the dashboard (no shared API secret in the browser)
|
||||
- **API key** (`SecretKey` in `api.yml`) still works for automation and scripts
|
||||
- Config saves can **restart the Discord bot** while keeping the dashboard and API online
|
||||
- Timeouts and caching so the UI does not hang when Discord or GitHub is slow
|
||||
|
||||
## Requirements
|
||||
|
||||
| Component | Version |
|
||||
|-----------|---------|
|
||||
| **Node.js** | 22.x (matches [SupportBot 8.x](https://github.com/Emerald-Services/SupportBot)) |
|
||||
| **discord.js** | 14.x (installed via `package.json`) |
|
||||
| **Discord bot** | Application with bot token and required intents |
|
||||
| **Discord OAuth app** | For dashboard login (optional but recommended) |
|
||||
|
||||
You need a machine or VPS that can run Node and reach Discord’s API (and GitHub, if you use in-dashboard updates or the addon marketplace).
|
||||
|
||||
## Getting started
|
||||
|
||||
### 1. Clone and install
|
||||
|
||||
```bash
|
||||
git clone https://github.com/C-h-a-r/SupportBot-Dashboard.git
|
||||
cd SupportBot-Dashboard
|
||||
npm install
|
||||
```
|
||||
|
||||
### 2. Configure the bot
|
||||
|
||||
Edit `Configs/supportbot.yml`:
|
||||
|
||||
- Set `General.Token` to your bot token
|
||||
- Set `General.GuildId` to your Discord server ID
|
||||
- Enable addons if you use them: `General.Addons.Enabled: true`
|
||||
|
||||
Other YAML files in `Configs/` can stay at defaults until you open the dashboard.
|
||||
|
||||
### 3. Configure the API & dashboard
|
||||
|
||||
Edit `Configs/api.yml`:
|
||||
|
||||
```yaml
|
||||
API:
|
||||
Enabled: true
|
||||
Port: 3000
|
||||
SecretKey: "generate-a-long-random-string"
|
||||
OAuth:
|
||||
Enabled: true
|
||||
ClientId: "YOUR_DISCORD_APP_CLIENT_ID"
|
||||
ClientSecret: "YOUR_DISCORD_APP_CLIENT_SECRET"
|
||||
RedirectUri: "http://localhost:3000/api/auth/discord/callback"
|
||||
OwnerUserIds:
|
||||
- "YOUR_DISCORD_USER_ID"
|
||||
```
|
||||
|
||||
**Discord Developer Portal**
|
||||
|
||||
1. Create an application at [discord.com/developers/applications](https://discord.com/developers/applications)
|
||||
2. Under **OAuth2 → Redirects**, add your callback URL(s). If users may sign in over **both** HTTP and HTTPS, register **both** (same host/path, different protocol), e.g. `http://bot.example.com/api/auth/discord/callback` and `https://bot.example.com/api/auth/discord/callback`.
|
||||
3. Use the same app’s **Client ID** and **Client Secret** in `api.yml`
|
||||
4. Put your Discord user ID in `OwnerUserIds` for full dashboard access
|
||||
|
||||
Set `RedirectUri` to your usual URL (host and path). By default, `UseRequestOrigin` keeps the **same protocol** as the page you opened (http vs https). Behind a reverse proxy, set `TrustProxy: 1` so forwarded `X-Forwarded-Proto` is respected.
|
||||
|
||||
### 4. Start the bot
|
||||
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
When startup succeeds you should see:
|
||||
|
||||
- `[Dashboard] API listening on http://localhost:3000`
|
||||
- Discord bot login and slash command registration
|
||||
|
||||
Open **http://localhost:3000** in your browser and sign in with Discord.
|
||||
|
||||
The web UI is shipped as static files in `public/dashboard/` (included in this repository). You do not need a separate frontend build step.
|
||||
|
||||
> **Tip:** On first run, invite the bot to your server with the permissions SupportBot needs. Set `General.GuildId` to that server’s ID so channel/role pickers and guild health checks work.
|
||||
|
||||
## Dashboard access
|
||||
|
||||
| Method | Use case |
|
||||
|--------|----------|
|
||||
| **Discord OAuth** | Normal use — open the site, click **Continue with Discord**, sign in as an owner or invited user |
|
||||
| **Bearer token** | Scripts and automation — `Authorization: Bearer <SecretKey>` on `/api/*` routes |
|
||||
|
||||
Owners listed in `api.yml` (`OwnerUserIds`) always have full access. Additional users can be added under **Users** in the dashboard with granular permissions per config file and page.
|
||||
|
||||
## Configuration
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Configs/supportbot.yml` | Bot token, guild, staff roles, ticket channels, embed colours, etc. |
|
||||
| `Configs/ticket-panel.yml` | Ticket panel layout and buttons |
|
||||
| `Configs/commands.yml` | Enable/disable slash commands |
|
||||
| `Configs/messages.yml` | User-facing message strings |
|
||||
| `Configs/supportbot-ai.yml` | AI assistant settings |
|
||||
| `Configs/api.yml` | Dashboard port, session secret, OAuth |
|
||||
| `Addons/Configs/*` | Per-addon config (after installing from **Addons** page) |
|
||||
|
||||
Most of these are editable from the sidebar under **Tickets & support**, **Community**, **Server control**, and **Addons**.
|
||||
|
||||
## Addons
|
||||
|
||||
1. Open **Addons** in the dashboard (or install manually into `Addons/` as in the [upstream docs](https://github.com/Emerald-Services/Addons))
|
||||
2. Click **Install** on an addon from the marketplace — files are copied into `Addons/` and `Addons/Configs/`, then the bot restarts
|
||||
3. Edit the addon’s config from the **Addons** section in the left sidebar (same editor as core configs)
|
||||
4. Ensure `General.Addons.Enabled` is `true` in bot config
|
||||
|
||||
Official addon list: [github.com/Emerald-Services/Addons](https://github.com/Emerald-Services/Addons)
|
||||
|
||||
## Upstream & credits
|
||||
|
||||
- **Original project:** [Emerald-Services/SupportBot](https://github.com/Emerald-Services/SupportBot) — MIT license, ticket bot for Discord
|
||||
- **Official addons:** [Emerald-Services/Addons](https://github.com/Emerald-Services/Addons)
|
||||
- **Community:** [Emerald Discord](https://emeraldsrv.dev/discord) · [Wiki](https://github.com/Emerald-Services/SupportBot/wiki)
|
||||
|
||||
This fork is maintained independently. For bugs in **core ticket/command behaviour** that also exist upstream, consider reporting to the [official repo](https://github.com/Emerald-Services/SupportBot/issues). For **dashboard-specific** issues, use this repository’s issues.
|
||||
|
||||
## License
|
||||
|
||||
Released under the [MIT](https://opensource.org/licenses/MIT) license.
|
||||
|
||||
|
||||
## Third-Party
|
||||
|
||||
- [Official addons repository](https://github.com/Emerald-Services/Addons/)
|
||||
- [Syphers Addons](https://github.com/SypherRed/SB_Addons_Unofficial)
|
||||
- [BuiltByBit]([https://builtbybit.com/supportbot](https://builtbybit.com/resources/supportbot-advanced-ticket-bot.8940/))
|
||||
- [Polymart](https://polymart.org/resource/supportbot-1-discord-ticket-bot.518)
|
||||
|
||||
|
||||
## Contributors
|
||||
|
||||
<a href="https://github.com/Emerald-Services/SupportBot/graphs/contributors">
|
||||
<img src="https://contrib.rocks/image?repo=Emerald-Services/SupportBot">
|
||||
</a>
|
||||
|
||||
## 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.
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
|
||||
Released under the [MIT License](https://opensource.org/licenses/MIT), same as upstream 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.
|
||||
@@ -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,180 @@
|
||||
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}`);
|
||||
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("");
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
applyPresence,
|
||||
logStartup,
|
||||
checkConfigs,
|
||||
logSlashCommandsRegistered,
|
||||
runBotReady,
|
||||
};
|
||||
+17
-12
@@ -2,11 +2,12 @@ const fs = require("fs");
|
||||
const path = require("path");
|
||||
const Discord = require("discord.js");
|
||||
const { GatewayIntentBits, Partials } = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
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 cmdconfig = yaml.load(fs.readFileSync("./Configs/commands.yml", "utf8"));
|
||||
const supportbot = configStore.supportbot;
|
||||
const cmdconfig = configStore.commands;
|
||||
|
||||
const c = {
|
||||
reset: "\x1b[0m",
|
||||
@@ -50,6 +51,7 @@ class Client extends Discord.Client {
|
||||
GatewayIntentBits.GuildMembers,
|
||||
GatewayIntentBits.GuildMessages,
|
||||
GatewayIntentBits.GuildMessageReactions,
|
||||
GatewayIntentBits.GuildEmojisAndStickers,
|
||||
GatewayIntentBits.MessageContent,
|
||||
],
|
||||
partials: [Partials.Message, Partials.Channel, Partials.Reaction],
|
||||
@@ -60,26 +62,29 @@ class Client extends Discord.Client {
|
||||
|
||||
async getChannel(channel, guild) {
|
||||
if (!channel) return null;
|
||||
const channelStr = String(channel).toLowerCase();
|
||||
return guild.channels.cache.find(
|
||||
(c) =>
|
||||
(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) {
|
||||
if (!role) return null;
|
||||
const roleStr = String(role).toLowerCase();
|
||||
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) {
|
||||
if (!category) return null;
|
||||
const catStr = String(category).toLowerCase();
|
||||
return guild.channels.cache.find(
|
||||
(c) =>
|
||||
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 () => {
|
||||
await this.guilds.cache.first()?.commands.set(this.commands);
|
||||
|
||||
console.log(
|
||||
`\n${c.green}✓${c.reset} ${c.white}Slash Commands Registered${c.reset} ${c.gray}for ${this.guilds.cache.first().name}${c.reset}`,
|
||||
);
|
||||
this.on("clientReady", async () => {
|
||||
const guild = getPrimaryGuild(this);
|
||||
if (!guild) return;
|
||||
await guild.commands.set(this.commands);
|
||||
const { logSlashCommandsRegistered } = require("./BotStartup.js");
|
||||
logSlashCommandsRegistered(this);
|
||||
});
|
||||
|
||||
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,135 @@
|
||||
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() {
|
||||
return {
|
||||
overview: true,
|
||||
logs: true,
|
||||
transcripts: true,
|
||||
settings: { view: true, update: true },
|
||||
configs: configMap(true, true),
|
||||
users: { view: true, manage: true },
|
||||
};
|
||||
}
|
||||
|
||||
function permissionsFromRole(role, customPermissions) {
|
||||
switch (role) {
|
||||
case "owner":
|
||||
case "admin":
|
||||
return fullPermissions();
|
||||
case "moderator":
|
||||
return {
|
||||
overview: true,
|
||||
logs: true,
|
||||
transcripts: true,
|
||||
settings: { view: true, update: false },
|
||||
configs: configMap(true, false),
|
||||
users: { view: false, manage: false },
|
||||
};
|
||||
case "editor":
|
||||
return {
|
||||
overview: true,
|
||||
logs: true,
|
||||
transcripts: false,
|
||||
settings: { view: true, update: false },
|
||||
configs: configMap(true, true),
|
||||
users: { view: false, manage: false },
|
||||
};
|
||||
case "viewer":
|
||||
return {
|
||||
overview: true,
|
||||
logs: true,
|
||||
transcripts: true,
|
||||
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,
|
||||
logs: input.logs ?? base.logs,
|
||||
transcripts: input.transcripts ?? base.transcripts,
|
||||
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 === "logs") return Boolean(permissions.logs);
|
||||
if (check === "transcripts") return Boolean(permissions.transcripts);
|
||||
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,440 @@
|
||||
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;
|
||||
|
||||
return [
|
||||
{
|
||||
id: "botToken",
|
||||
title: "Bot token",
|
||||
description: "From the Discord Developer Portal → your application → Bot → Reset Token.",
|
||||
complete: isValidBotToken(token),
|
||||
},
|
||||
{
|
||||
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 validateSetupPayload(payload) {
|
||||
const results = {};
|
||||
|
||||
if (payload.botToken != null) {
|
||||
results.botToken = await validateBotToken(payload.botToken);
|
||||
}
|
||||
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"));
|
||||
sbDoc.setIn(["General", "Token"], String(payload.botToken).trim());
|
||||
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,291 @@
|
||||
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);
|
||||
}
|
||||
|
||||
function resolvePermissions(entry, oauth, userId) {
|
||||
if (isYamlOwner(userId, oauth)) {
|
||||
return { permissions: fullPermissions(), role: "owner", isOwner: true };
|
||||
}
|
||||
if (!entry?.enabled) return null;
|
||||
|
||||
const role = entry.role || "viewer";
|
||||
const permissions =
|
||||
role === "custom"
|
||||
? permissionsFromRole("custom", entry.permissions)
|
||||
: permissionsFromRole(role);
|
||||
|
||||
return { permissions, role, isOwner: false };
|
||||
}
|
||||
|
||||
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) {
|
||||
bootstrapFromOAuth(oauth);
|
||||
const store = readStore();
|
||||
const yamlOwners = getOwnerIds(oauth);
|
||||
|
||||
return Object.values(store.users).map((entry) => {
|
||||
const access = resolvePermissions(entry, oauth, entry.id) || {
|
||||
permissions: permissionsFromRole("viewer"),
|
||||
role: entry.role,
|
||||
isOwner: false,
|
||||
};
|
||||
|
||||
return {
|
||||
id: entry.id,
|
||||
username: entry.username,
|
||||
globalName: entry.globalName,
|
||||
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,
|
||||
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,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
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 || "viewer";
|
||||
if (!ROLES.includes(role)) throw new Error("Invalid role");
|
||||
if (role === "owner" && !isYamlOwner(actorId, oauth)) {
|
||||
throw new Error("Only api.yml owners can assign the owner role");
|
||||
}
|
||||
|
||||
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,
|
||||
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.role) {
|
||||
if (!ROLES.includes(payload.role)) throw new Error("Invalid role");
|
||||
if (payload.role === "owner" && !isYamlOwner(actorId, oauth)) {
|
||||
throw new Error("Only api.yml owners can assign the owner role");
|
||||
}
|
||||
if (isYamlOwner(id, oauth) && payload.role !== "owner") {
|
||||
throw new Error("Cannot change role of an api.yml owner");
|
||||
}
|
||||
entry.role = 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,
|
||||
};
|
||||
@@ -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,276 @@
|
||||
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,
|
||||
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,
|
||||
};
|
||||
+9
-395
@@ -1,11 +1,12 @@
|
||||
const fs = require("fs");
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const Discord = require("discord.js");
|
||||
const yaml = require("js-yaml");
|
||||
const db = require("./Database.js");
|
||||
|
||||
const supportbot = yaml.load(fs.readFileSync("./Configs/supportbot.yml", "utf8"));
|
||||
const msgconfig = yaml.load(fs.readFileSync("./Configs/messages.yml", "utf8"));
|
||||
const supportbot = require("./ConfigStore").supportbot;
|
||||
const msgconfig = require("./ConfigStore").messages;
|
||||
const { createTranscriptHTML } = require("./TranscriptTemplate.js");
|
||||
|
||||
function escapeHtml(str = "") {
|
||||
return String(str)
|
||||
@@ -68,7 +69,7 @@ function renderAttachment(att) {
|
||||
if (type === "image") {
|
||||
return `
|
||||
<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">
|
||||
<img src="${url}" alt="${name}" loading="lazy">
|
||||
</div>
|
||||
@@ -79,7 +80,7 @@ function renderAttachment(att) {
|
||||
if (type === "video") {
|
||||
return `
|
||||
<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">
|
||||
<video controls preload="metadata">
|
||||
<source src="${url}">
|
||||
@@ -92,7 +93,7 @@ function renderAttachment(att) {
|
||||
if (type === "audio") {
|
||||
return `
|
||||
<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">
|
||||
<audio controls preload="metadata">
|
||||
<source src="${url}">
|
||||
@@ -104,7 +105,7 @@ function renderAttachment(att) {
|
||||
|
||||
return `
|
||||
<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>
|
||||
`;
|
||||
}
|
||||
@@ -134,393 +135,6 @@ function renderEmbeds(embeds = []) {
|
||||
.join("");
|
||||
}
|
||||
|
||||
function createTranscriptHTML(ticket, reason) {
|
||||
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>
|
||||
<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;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -633,4 +247,4 @@ async function createTranscript(interaction, ticket, reason) {
|
||||
|
||||
module.exports = {
|
||||
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,680 @@
|
||||
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, "<strong>$1</strong>");
|
||||
safe = safe.replace(/\*(.*?)\*/g, "<em>$1</em>");
|
||||
safe = safe.replace(/_(.*?)_/g, "<em>$1</em>");
|
||||
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 fields = (embed.fields || [])
|
||||
.map(
|
||||
(field) => `
|
||||
<div class="embed-field">
|
||||
<div class="embed-field-name">${escapeHtml(field.name || "Field")}</div>
|
||||
<div class="embed-field-value">${parseMarkdown(field.value || "")}</div>
|
||||
</div>
|
||||
`,
|
||||
)
|
||||
.join("");
|
||||
|
||||
return `
|
||||
<div class="discord-embed">
|
||||
${embed.title ? `<div class="discord-embed-title">${escapeHtml(embed.title)}</div>` : ""}
|
||||
${embed.description ? `<div class="discord-embed-description">${parseMarkdown(embed.description)}</div>` : ""}
|
||||
${fields}
|
||||
</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-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: 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,23 +13,33 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const yaml = require("js-yaml");
|
||||
const supportbot = yaml.load(
|
||||
fs.readFileSync("./Configs/supportbot.yml", "utf8")
|
||||
);
|
||||
const configStore = require("./Structures/ConfigStore.js");
|
||||
|
||||
const Client = require("./Structures/Client.js");
|
||||
const client = new Client({
|
||||
intents: ['Guilds', 'GuildMembers', 'GuildMessages', 'MessageContent']
|
||||
intents: ["Guilds", "GuildMembers", "GuildMessages", "MessageContent"],
|
||||
});
|
||||
|
||||
const APIServer = require("./API/server.js");
|
||||
|
||||
client.start(supportbot.General.Token);
|
||||
// Start dashboard API immediately — do not wait for Discord clientReady
|
||||
const api = new APIServer(client);
|
||||
client.apiServer = api;
|
||||
|
||||
client.once('ready', () => {
|
||||
const api = new APIServer(client);
|
||||
api.start();
|
||||
if (api.config?.Enabled) {
|
||||
const port = api.config.Port || 3000;
|
||||
api.start(port);
|
||||
console.log(
|
||||
`[Dashboard] API listening on http://localhost:${port} (bot may still be connecting)`,
|
||||
);
|
||||
} else {
|
||||
console.warn("[Dashboard] API disabled in Configs/api.yml");
|
||||
}
|
||||
|
||||
client.start(configStore.supportbot.General.Token);
|
||||
|
||||
client.on("clientReady", () => {
|
||||
console.log("[Dashboard] Discord bot is ready — full stats and guild tools are available");
|
||||
});
|
||||
|
||||
// SupportBot - New Logging System
|
||||
@@ -77,14 +87,3 @@ process.on("unhandledRejection", (reason) => {
|
||||
process.on("uncaughtException", (err) => {
|
||||
console.error("Uncaught Exception:", err);
|
||||
});
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
Generated
+2489
File diff suppressed because it is too large.
Load diff
+6
-3
@@ -1,11 +1,12 @@
|
||||
{
|
||||
"name": "supportbot",
|
||||
"version": "26.0.0-prerelease-1",
|
||||
"version": "26.2",
|
||||
"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",
|
||||
"scripts": {
|
||||
"setup": "npm i && node index.js",
|
||||
"start": "node index.js"
|
||||
"start": "node index.js",
|
||||
"git:setup": "node scripts/setup-git-security.js"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
@@ -19,8 +20,10 @@
|
||||
"homepage": "https://community.emeraldsrv.dev",
|
||||
"dependencies": {
|
||||
"@vitalets/google-translate-api": "^9.2.1",
|
||||
"adm-zip": "^0.6.0",
|
||||
"axios": "^1.7.2",
|
||||
"better-sqlite3": "^12.2.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.6",
|
||||
"discord.js": "^14.13.0",
|
||||
"express": "^5.2.1",
|
||||
@@ -35,4 +38,4 @@
|
||||
"systeminformation": "^5.31.5",
|
||||
"yaml": "^2.8.3"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,206 @@
|
||||
var i3=Object.defineProperty;var l3=(e,n,a)=>n in e?i3(e,n,{enumerable:!0,configurable:!0,writable:!0,value:a}):e[n]=a;var Mx=(e,n,a)=>l3(e,typeof n!="symbol"?n+"":n,a);function c3(e,n){for(var a=0;a<n.length;a++){const o=n[a];if(typeof o!="string"&&!Array.isArray(o)){for(const i in o)if(i!=="default"&&!(i in e)){const c=Object.getOwnPropertyDescriptor(o,i);c&&Object.defineProperty(e,i,c.get?c:{enumerable:!0,get:()=>o[i]})}}}return Object.freeze(Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}))}(function(){const n=document.createElement("link").relList;if(n&&n.supports&&n.supports("modulepreload"))return;for(const i of document.querySelectorAll('link[rel="modulepreload"]'))o(i);new MutationObserver(i=>{for(const c of i)if(c.type==="childList")for(const d of c.addedNodes)d.tagName==="LINK"&&d.rel==="modulepreload"&&o(d)}).observe(document,{childList:!0,subtree:!0});function a(i){const c={};return i.integrity&&(c.integrity=i.integrity),i.referrerPolicy&&(c.referrerPolicy=i.referrerPolicy),i.crossOrigin==="use-credentials"?c.credentials="include":i.crossOrigin==="anonymous"?c.credentials="omit":c.credentials="same-origin",c}function o(i){if(i.ep)return;i.ep=!0;const c=a(i);fetch(i.href,c)}})();function w0(e){return e&&e.__esModule&&Object.prototype.hasOwnProperty.call(e,"default")?e.default:e}var Fm={exports:{}},Li={};/**
|
||||
* @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 _x;function u3(){if(_x)return Li;_x=1;var e=Symbol.for("react.transitional.element"),n=Symbol.for("react.fragment");function a(o,i,c){var d=null;if(c!==void 0&&(d=""+c),i.key!==void 0&&(d=""+i.key),"key"in i){c={};for(var m in i)m!=="key"&&(c[m]=i[m])}else c=i;return i=c.ref,{$$typeof:e,type:o,key:d,ref:i!==void 0?i:null,props:c}}return Li.Fragment=n,Li.jsx=a,Li.jsxs=a,Li}var Ox;function d3(){return Ox||(Ox=1,Fm.exports=u3()),Fm.exports}var l=d3(),Ym={exports:{}},Me={};/**
|
||||
* @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 Dx;function f3(){if(Dx)return Me;Dx=1;var e=Symbol.for("react.transitional.element"),n=Symbol.for("react.portal"),a=Symbol.for("react.fragment"),o=Symbol.for("react.strict_mode"),i=Symbol.for("react.profiler"),c=Symbol.for("react.consumer"),d=Symbol.for("react.context"),m=Symbol.for("react.forward_ref"),p=Symbol.for("react.suspense"),h=Symbol.for("react.memo"),y=Symbol.for("react.lazy"),v=Symbol.for("react.activity"),x=Symbol.iterator;function S(M){return M===null||typeof M!="object"?null:(M=x&&M[x]||M["@@iterator"],typeof M=="function"?M:null)}var j={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},C=Object.assign,N={};function E(M,_,U){this.props=M,this.context=_,this.refs=N,this.updater=U||j}E.prototype.isReactComponent={},E.prototype.setState=function(M,_){if(typeof M!="object"&&typeof M!="function"&&M!=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,M,_,"setState")},E.prototype.forceUpdate=function(M){this.updater.enqueueForceUpdate(this,M,"forceUpdate")};function k(){}k.prototype=E.prototype;function T(M,_,U){this.props=M,this.context=_,this.refs=N,this.updater=U||j}var R=T.prototype=new k;R.constructor=T,C(R,E.prototype),R.isPureReactComponent=!0;var D=Array.isArray;function P(){}var z={H:null,A:null,T:null,S:null},B=Object.prototype.hasOwnProperty;function H(M,_,U){var V=U.ref;return{$$typeof:e,type:M,key:_,ref:V!==void 0?V:null,props:U}}function Y(M,_){return H(M.type,_,M.props)}function Q(M){return typeof M=="object"&&M!==null&&M.$$typeof===e}function fe(M){var _={"=":"=0",":":"=2"};return"$"+M.replace(/[=:]/g,function(U){return _[U]})}var de=/\/+/g;function he(M,_){return typeof M=="object"&&M!==null&&M.key!=null?fe(""+M.key):_.toString(36)}function se(M){switch(M.status){case"fulfilled":return M.value;case"rejected":throw M.reason;default:switch(typeof M.status=="string"?M.then(P,P):(M.status="pending",M.then(function(_){M.status==="pending"&&(M.status="fulfilled",M.value=_)},function(_){M.status==="pending"&&(M.status="rejected",M.reason=_)})),M.status){case"fulfilled":return M.value;case"rejected":throw M.reason}}throw M}function O(M,_,U,V,F){var ae=typeof M;(ae==="undefined"||ae==="boolean")&&(M=null);var ne=!1;if(M===null)ne=!0;else switch(ae){case"bigint":case"string":case"number":ne=!0;break;case"object":switch(M.$$typeof){case e:case n:ne=!0;break;case y:return ne=M._init,O(ne(M._payload),_,U,V,F)}}if(ne)return F=F(M),ne=V===""?"."+he(M,0):V,D(F)?(U="",ne!=null&&(U=ne.replace(de,"$&/")+"/"),O(F,_,U,"",function(W){return W})):F!=null&&(Q(F)&&(F=Y(F,U+(F.key==null||M&&M.key===F.key?"":(""+F.key).replace(de,"$&/")+"/")+ne)),_.push(F)),1;ne=0;var me=V===""?".":V+":";if(D(M))for(var pe=0;pe<M.length;pe++)V=M[pe],ae=me+he(V,pe),ne+=O(V,_,U,ae,F);else if(pe=S(M),typeof pe=="function")for(M=pe.call(M),pe=0;!(V=M.next()).done;)V=V.value,ae=me+he(V,pe++),ne+=O(V,_,U,ae,F);else if(ae==="object"){if(typeof M.then=="function")return O(se(M),_,U,V,F);throw _=String(M),Error("Objects are not valid as a React child (found: "+(_==="[object Object]"?"object with keys {"+Object.keys(M).join(", ")+"}":_)+"). If you meant to render a collection of children, use an array instead.")}return ne}function $(M,_,U){if(M==null)return M;var V=[],F=0;return O(M,V,"","",function(ae){return _.call(U,ae,F++)}),V}function Z(M){if(M._status===-1){var _=M._result;_=_(),_.then(function(U){(M._status===0||M._status===-1)&&(M._status=1,M._result=U)},function(U){(M._status===0||M._status===-1)&&(M._status=2,M._result=U)}),M._status===-1&&(M._status=0,M._result=_)}if(M._status===1)return M._result.default;throw M._result}var J=typeof reportError=="function"?reportError:function(M){if(typeof window=="object"&&typeof window.ErrorEvent=="function"){var _=new window.ErrorEvent("error",{bubbles:!0,cancelable:!0,message:typeof M=="object"&&M!==null&&typeof M.message=="string"?String(M.message):String(M),error:M});if(!window.dispatchEvent(_))return}else if(typeof process=="object"&&typeof process.emit=="function"){process.emit("uncaughtException",M);return}console.error(M)},te={map:$,forEach:function(M,_,U){$(M,function(){_.apply(this,arguments)},U)},count:function(M){var _=0;return $(M,function(){_++}),_},toArray:function(M){return $(M,function(_){return _})||[]},only:function(M){if(!Q(M))throw Error("React.Children.only expected to receive a single React element child.");return M}};return Me.Activity=v,Me.Children=te,Me.Component=E,Me.Fragment=a,Me.Profiler=i,Me.PureComponent=T,Me.StrictMode=o,Me.Suspense=p,Me.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=z,Me.__COMPILER_RUNTIME={__proto__:null,c:function(M){return z.H.useMemoCache(M)}},Me.cache=function(M){return function(){return M.apply(null,arguments)}},Me.cacheSignal=function(){return null},Me.cloneElement=function(M,_,U){if(M==null)throw Error("The argument must be a ReaLine 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 zx;function m3(){return zx||(zx=1,(function(e){function n(O,$){var Z=O.length;O.push($);e:for(;0<Z;){var J=Z-1>>>1,te=O[J];if(0<i(te,$))O[J]=$,O[Z]=te,Z=J;else break e}}function a(O){return O.length===0?null:O[0]}function o(O){if(O.length===0)return null;var $=O[0],Z=O.pop();if(Z!==$){O[0]=Z;e:for(var J=0,te=O.length,M=te>>>1;J<M;){var _=2*(J+1)-1,U=O[_],V=_+1,F=O[V];if(0>i(U,Z))V<te&&0>i(F,U)?(O[J]=F,O[V]=Z,J=V):(O[J]=U,O[_]=Z,J=_);else if(V<te&&0>i(F,Z))O[J]=F,O[V]=Z,J=V;else break e}}return $}function i(O,$){var Z=O.sortIndex-$.sortIndex;return Z!==0?Z:O.id-$.id}if(e.unstable_now=void 0,typeof performance=="object"&&typeof performance.now=="function"){var c=performance;e.unstable_now=function(){return c.now()}}else{var d=Date,m=d.now();e.unstable_now=function(){return d.now()-m}}var p=[],h=[],y=1,v=null,x=3,S=!1,j=!1,C=!1,N=!1,E=typeof setTimeout=="function"?setTimeout:null,k=typeof clearTimeout=="function"?clearTimeout:null,T=typeof setImmediate<"u"?setImmediate:null;function R(O){for(var $=a(h);$!==null;){if($.callback===null)o(h);else if($.startTime<=O)o(h),$.sortIndex=$.expirationTime,n(p,$);else break;$=a(h)}}function D(O){if(C=!1,R(O),!j)if(a(p)!==null)j=!0,P||(P=!0,fe());else{var $=a(h);$!==null&&se(D,$.startTime-O)}}var P=!1,z=-1,B=5,H=-1;function Y(){return N?!0:!(e.unstable_now()-H<B)}function Q(){if(N=!1,P){var O=e.unstable_now();H=O;var $=!0;try{e:{j=!1,C&&(C=!1,k(z),z=-1),S=!0;var Z=x;try{t:{for(R(O),v=a(p);v!==null&&!(v.expirationTime>O&&Y());){var J=v.callback;if(typeof J=="function"){v.callback=null,x=v.priorityLevel;var te=J(v.expirationTime<=O);if(O=e.unstable_now(),typeof te=="function"){v.callback=te,R(O),$=!0;break t}v===a(p)&&o(p),R(O)}else o(p);v=a(p)}if(v!==null)$=!0;else{var M=a(h);M!==null&&se(D,M.startTime-O),$=!1}}break e}finally{v=null,x=Z,S=!1}$=void 0}}finally{$?fe():P=!1}}}var fe;if(typeof T=="function")fe=function(){T(Q)};else if(typeof MessageChannel<"u"){var de=new MessageChannel,he=de.port2;de.port1.onmessage=Q,fe=function(){he.postMessage(null)}}else fe=function(){E(Q,0)};function se(O,$){z=E(function(){O(e.unstable_now())},$)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(O){O.callback=null},e.unstable_forceFrameRate=function(O){0>O||125<O?console.error("forceFrameRate takes a positive int between 0 and 125, forcing frame rates higher than 125 fps is not supported"):B=0<O?Math.floor(1e3/O):5},e.unstable_getCurrentPriorityLevel=function(){return x},e.unstable_next=function(O){switch(x){case 1:case 2:case 3:var $=3;break;default:$=x}var Z=x;x=$;try{return O()}finally{x=Z}},e.unstable_requestPaint=function(){N=!0},e.unstable_runWithPriority=function(O,$){switch(O){case 1:case 2:case 3:case 4:case 5:break;default:O=3}var Z=x;x=O;try{return $()}finally{x=Z}},e.unstable_scheduleCallback=function(O,$,Z){var J=e.unstable_now();switch(typeof Z=="object"&&Z!==null?(Z=Z.delay,Z=typeof Z=="number"&&0<Z?J+Z:J):Z=J,O){case 1:var te=-1;break;case 2:te=250;break;case 5:te=1073741823;break;case 4:te=1e4;break;default:te=5e3}return te=Z+te,O={id:y++,callback:$,priorityLevel:O,startTime:Z,expirationTime:te,sortIndex:-1},Z>J?(O.sortIndex=Z,n(h,O),a(p)===null&&O===a(h)&&(C?(k(z),z=-1):C=!0,se(D,Z-J))):(O.sortIndex=te,n(p,O),j||S||(j=!0,P||(P=!0,fe()))),O},e.unstable_shouldYield=Y,e.unstable_wrapCallback=function(O){var $=x;return function(){var Z=x;x=$;try{return O.apply(this,arguments)}finally{x=Z}}}})(Km)),Km}var Px;function p3(){return Px||(Px=1,qm.exports=m3()),qm.exports}var Xm={exports:{}},Ft={};/**
|
||||
* @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 Ix;function h3(){if(Ix)return Ft;Ix=1;var e=Lu();function n(p){var h="https://react.dev/errors/"+p;if(1<arguments.length){h+="?args[]="+encodeURIComponent(arguments[1]);for(var y=2;y<arguments.length;y++)h+="&args[]="+encodeURIComponent(arguments[y])}return"Minified React error #"+p+"; visit "+h+" 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},i=Symbol.for("react.portal");function c(p,h,y){var v=3<arguments.length&&arguments[3]!==void 0?arguments[3]:null;return{$$typeof:i,key:v==null?null:""+v,children:p,containerInfo:h,implementation:y}}var d=e.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE;function m(p,h){if(p==="font")return"";if(typeof h=="string")return h==="use-credentials"?h:""}return Ft.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE=o,Ft.createPortal=function(p,h){var y=2<arguments.length&&arguments[2]!==void 0?arguments[2]:null;if(!h||h.nodeType!==1&&h.nodeType!==9&&h.nodeType!==11)throw Error(n(299));return c(p,h,null,y)},Ft.flushSync=function(p){var h=d.T,y=o.p;try{if(d.T=null,o.p=2,p)return p()}finally{d.T=h,o.p=y,o.d.f()}},Ft.preconnect=function(p,h){typeof p=="string"&&(h?(h=h.crossOrigin,h=typeof h=="string"?h==="use-credentials"?h:"":void 0):h=null,o.d.C(p,h))},Ft.prefetchDNS=function(p){typeof p=="string"&&o.d.D(p)},Ft.preinit=function(p,h){if(typeof p=="string"&&h&&typeof h.as=="string"){var y=h.as,v=m(y,h.crossOrigin),x=typeof h.integrity=="string"?h.integrity:void 0,S=typeof h.fetchPriority=="string"?h.fetchPriority:void 0;y==="style"?o.d.S(p,typeof h.precedence=="string"?h.precedence:void 0,{crossOrigin:v,integrity:x,fetchPriority:S}):y==="script"&&o.d.X(p,{crossOrigin:v,integrity:x,fetchPriority:S,nonce:typeof h.nonce=="string"?h.nonce:void 0})}},Ft.preinitModule=function(p,h){if(typeof p=="string")if(typeof h=="object"&&h!==null){if(h.as==null||h.as==="script"){var y=m(h.as,h.crossOrigin);o.d.M(p,{crossOrigin:y,integrity:typeof h.integrity=="string"?h.integrity:void 0,nonce:typeof h.nonce=="string"?h.nonce:void 0})}}else h==null&&o.d.M(p)},Ft.preload=function(p,h){if(typeof p=="string"&&typeof h=="object"&&h!==null&&typeof h.as=="string"){var y=h.as,v=m(y,h.crossOrigin);o.d.L(p,y,{crossOrigin:v,integrity:typeof h.integrity=="string"?h.integrity:void 0,nonce:typeof h.nonce=="string"?h.nonce:void 0,type:typeof h.type=="string"?h.type:void 0,fetchPriority:typeof h.fetchPriority=="string"?h.fetchPriority:void 0,referrerPolicy:typeof h.referrerPolicy=="string"?h.referrerPolicy:void 0,imageSrcSet:typeof h.imageSrcSet=="string"?h.imageSrcSet:void 0,imageSizes:typeof h.imageSizes=="string"?h.imageSizes:void 0,media:typeof h.media=="string"?h.media:void 0})}},Ft.preloadModule=function(p,h){if(typeof p=="string")if(h){var y=m(h.as,h.crossOrigin);o.d.m(p,{as:typeof h.as=="string"&&h.as!=="script"?h.as:void 0,crossOrigin:y,integrity:typeof h.integrity=="string"?h.integrity:void 0})}else o.d.m(p)},Ft.requestFormReset=function(p){o.d.r(p)},Ft.unstable_batchedUpdates=function(p,h){return p(h)},Ft.useFormState=function(p,h,y){return d.H.useFormState(p,h,y)},Ft.useFormStatus=function(){return d.H.useHostTransitionStatus()},Ft.version="19.2.6",Ft}var $x;function C0(){if($x)return Xm.exports;$x=1;function e(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(e)}catch(n){console.error(n)}}return e(),Xm.exports=h3(),Xm.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 Bx;function g3(){if(Bx)return zi;Bx=1;var e=p3(),n=Lu(),a=C0();function o(t){var r="https://react.dev/errors/"+t;if(1<arguments.length){r+="?args[]="+encodeURIComponent(arguments[1]);for(var s=2;s<arguments.length;s++)r+="&args[]="+encodeURIComponent(arguments[s])}return"Minified React error #"+t+"; visit "+r+" for the full message or use the non-minified dev environment for full errors and additional helpful warnings."}function i(t){return!(!t||t.nodeType!==1&&t.nodeType!==9&&t.nodeType!==11)}function c(t){var r=t,s=t;if(t.alternate)for(;r.return;)r=r.return;else{t=r;do r=t,(r.flags&4098)!==0&&(s=r.return),t=r.return;while(t)}return r.tag===3?s:null}function d(t){if(t.tag===13){var r=t.memoizedState;if(r===null&&(t=t.alternate,t!==null&&(r=t.memoizedState)),r!==null)return r.dehydrated}return null}function m(t){if(t.tag===31){var r=t.memoizedState;if(r===null&&(t=t.alternate,t!==null&&(r=t.memoizedState)),r!==null)return r.dehydrated}return null}function p(t){if(c(t)!==t)throw Error(o(188))}function h(t){var r=t.alternate;if(!r){if(r=c(t),r===null)throw Error(o(188));return r!==t?null:t}for(var s=t,u=r;;){var g=s.return;if(g===null)break;var b=g.alternate;if(b===null){if(u=g.return,u!==null){s=u;continue}break}if(g.child===b.child){for(b=g.child;b;){if(b===s)return p(g),t;if(b===u)return p(g),r;b=b.sibling}throw Error(o(188))}if(s.return!==u.return)s=g,u=b;else{for(var w=!1,A=g.child;A;){if(A===s){w=!0,s=g,u=b;break}if(A===u){w=!0,u=g,s=b;break}A=A.sibling}if(!w){for(A=b.child;A;){if(A===s){w=!0,s=b,u=g;break}if(A===u){w=!0,u=b,s=g;break}A=A.sibling}if(!w)throw Error(o(189))}}if(s.alternate!==u)throw Error(o(190))}if(s.tag!==3)throw Error(o(188));return s.stateNode.current===s?t:r}function y(t){var r=t.tag;if(r===5||r===26||r===27||r===6)return t;for(t=t.child;t!==null;){if(r=y(t),r!==null)return r;t=t.sibling}return null}var v=Object.assign,x=Symbol.for("react.element"),S=Symbol.for("react.transitional.element"),j=Symbol.for("react.portal"),C=Symbol.for("react.fragment"),N=Symbol.for("react.strict_mode"),E=Symbol.for("react.profiler"),k=Symbol.for("react.consumer"),T=Symbol.for("react.context"),R=Symbol.for("react.forward_ref"),D=Symbol.for("react.suspense"),P=Symbol.for("react.suspense_list"),z=Symbol.for("react.memo"),B=Symbol.for("react.lazy"),H=Symbol.for("react.activity"),Y=Symbol.for("react.memo_cache_sentinel"),Q=Symbol.iterator;function fe(t){return t===null||typeof t!="object"?null:(t=Q&&t[Q]||t["@@iterator"],typeof t=="function"?t:null)}var de=Symbol.for("react.client.reference");function he(t){if(t==null)return null;if(typeof t=="function")return t.$$typeof===de?null:t.displayName||t.name||null;if(typeof t=="string")return t;switch(t){case C:return"Fragment";case E:return"Profiler";case N:return"StrictMode";case D:return"Suspense";case P:return"SuspenseList";case H:return"Activity"}if(typeof t=="object")switch(t.$$typeof){case j:return"Portal";case T:return t.displayName||"Context";case k:return(t._context.displayName||"Context")+".Consumer";case R:var r=t.render;return t=t.displayName,t||(t=r.displayName||r.name||"",t=t!==""?"ForwardRef("+t+")":"ForwardRef"),t;case z:return r=t.displayName||null,r!==null?r:he(t.type)||"Memo";case B:r=t._payload,t=t._init;try{return he(t(r))}catch{}}return null}var se=Array.isArray,O=n.__CLIENT_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,$=a.__DOM_INTERNALS_DO_NOT_USE_OR_WARN_USERS_THEY_CANNOT_UPGRADE,Z={pending:!1,data:null,method:null,action:null},J=[],te=-1;function M(t){return{current:t}}function _(t){0>te||(t.current=J[te],J[te]=null,te--)}function U(t,r){te++,J[te]=t.current,t.current=r}var V=M(null),F=M(null),ae=M(null),ne=M(null);function me(t,r){switch(U(ae,r),U(F,t),U(V,null),r.nodeType){case 9:case 11:t=(t=r.documentElement)&&(t=t.namespaceURI)?ex(t):0;break;default:if(t=r.tagName,r=r.namespaceURI)r=ex(r),t=tx(r,t);else switch(t){case"svg":t=1;break;case"math":t=2;break;default:t=0}}_(V),U(V,t)}function pe(){_(V),_(F),_(ae)}function W(t){t.memoizedState!==null&&U(ne,t);var r=V.current,s=tx(r,t.type);r!==s&&(U(F,t),U(V,s))}function ie(t){F.current===t&&(_(V),_(F)),ne.current===t&&(_(ne),Mi._currentValue=Z)}var ge,je;function Ae(t){if(ge===void 0)try{throw Error()}catch(s){var r=s.stack.trim().match(/\n( *(at )?)/);ge=r&&r[1]||"",je=-1<s.stack.indexOf(`
|
||||
at`)?" (<anonymous>)":-1<s.stack.indexOf("@")?"@unknown:0:0":""}return`
|
||||
`+ge+t+je}var tt=!1;function ct(t,r){if(!t||tt)return"";tt=!0;var s=Error.prepareStackTrace;Error.prepareStackTrace=void 0;try{var u={DetermineComponentFrameRoot:function(){try{if(r){var le=function(){throw Error()};if(Object.defineProperty(le.prototype,"props",{set:function(){throw Error()}}),typeof Reflect=="object"&&Reflect.construct){try{Reflect.construct(le,[])}catch(ee){var X=ee}Reflect.construct(t,[],le)}else{try{le.call()}catch(ee){X=ee}t.call(le.prototype)}}else{try{throw Error()}catch(ee){X=ee}(le=t())&&typeof le.catch=="function"&&le.catch(function(){})}}catch(ee){if(ee&&X&&typeof ee.stack=="string")return[ee.stack,X.stack]}return[null,null]}};u.DetermineComponentFrameRoot.displayName="DetermineComponentFrameRoot";var g=Object.getOwnPropertyDescriptor(u.DetermineComponentFrameRoot,"name");g&&g.configurable&&Object.defineProperty(u.DetermineComponentFrameRoot,"name",{value:"DetermineComponentFrameRoot"});var b=u.DetermineComponentFrameRoot(),w=b[0],A=b[1];if(w&&A){var L=w.split(`
|
||||
`),K=A.split(`
|
||||
`);for(g=u=0;u<L.length&&!L[u].includes("DetermineComponentFrameRoot");)u++;for(;g<K.length&&!K[g].includes("DetermineComponentFrameRoot");)g++;if(u===L.length||g===K.length)for(u=L.length-1,g=K.length-1;1<=u&&0<=g&&L[u]!==K[g];)g--;for(;1<=u&&0<=g;u--,g--)if(L[u]!==K[g]){if(u!==1||g!==1)do if(u--,g--,0>g||L[u]!==K[g]){var re=`
|
||||
`+L[u].replace(" at new "," at ");return t.displayName&&re.includes("<anonymous>")&&(re=re.replace("<anonymous>",t.displayName)),re}while(1<=u&&0<=g);break}}}finally{tt=!1,Error.prepareStackTrace=s}return(s=t?t.displayName||t.name:"")?Ae(s):""}function kt(t,r){switch(t.tag){case 26:case 27:case 5:return Ae(t.type);case 16:return Ae("Lazy");case 13:return t.child!==r&&r!==null?Ae("Suspense Fallback"):Ae("Suspense");case 19:return Ae("SuspenseList");case 0:case 15:return ct(t.type,!1);case 11:return ct(t.type.render,!1);case 1:return ct(t.type,!0);case 31:return Ae("Activity");default:return""}}function Ra(t){try{var r="",s=null;do r+=kt(t,s),s=t,t=t.return;while(t);return r}catch(u){return`
|
||||
Error generating stack: `+u.message+`
|
||||
`+u.stack}}var Tt=Object.prototype.hasOwnProperty,Hs=e.unstable_scheduleCallback,Vs=e.unstable_cancelCallback,Wt=e.unstable_shouldYield,Vr=e.unstable_requestPaint,qt=e.unstable_now,_d=e.unstable_getCurrentPriorityLevel,Aa=e.unstable_ImmediatePriority,Tl=e.unstable_UserBlockingPriority,Ma=e.unstable_NormalPriority,Gs=e.unstable_LowPriority,mr=e.unstable_IdlePriority,Rl=e.log,Gr=e.unstable_setDisableYieldValue,_a=null,Kt=null;function Un(t){if(typeof Rl=="function"&&Gr(t),Kt&&typeof Kt.setStrictMode=="function")try{Kt.setStrictMode(_a,t)}catch{}}var Vt=Math.clz32?Math.clz32:Qn,Od=Math.log,Fs=Math.LN2;function Qn(t){return t>>>=0,t===0?32:31-(Od(t)/Fs|0)|0}var yo=256,xo=262144,Oa=4194304;function Jn(t){var r=t&42;if(r!==0)return r;switch(t&-t){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 t&261888;case 262144:case 524288:case 1048576:case 2097152:return t&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return t&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return t}}function Re(t,r,s){var u=t.pendingLanes;if(u===0)return 0;var g=0,b=t.suspendedLanes,w=t.pingedLanes;t=t.warmLanes;var A=u&134217727;return A!==0?(u=A&~b,u!==0?g=Jn(u):(w&=A,w!==0?g=Jn(w):s||(s=A&~t,s!==0&&(g=Jn(s))))):(A=u&~b,A!==0?g=Jn(A):w!==0?g=Jn(w):s||(s=u&~t,s!==0&&(g=Jn(s)))),g===0?0:r!==0&&r!==g&&(r&b)===0&&(b=g&-g,s=r&-r,b>=s||b===32&&(s&4194048)!==0)?r:g}function ut(t,r){return(t.pendingLanes&~(t.suspendedLanes&~t.pingedLanes)&r)===0}function Rt(t,r){switch(t){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 Gt(){var t=Oa;return Oa<<=1,(Oa&62914560)===0&&(Oa=4194304),t}function Fr(t){for(var r=[],s=0;31>s;s++)r.push(t);return r}function mt(t,r){t.pendingLanes|=r,r!==268435456&&(t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0)}function Jt(t,r,s,u,g,b){var w=t.pendingLanes;t.pendingLanes=s,t.suspendedLanes=0,t.pingedLanes=0,t.warmLanes=0,t.expiredLanes&=s,t.entangledLanes&=s,t.errorRecoveryDisabledLanes&=s,t.shellSuspendCounter=0;var A=t.entanglements,L=t.expirationTimes,K=t.hiddenUpdates;for(s=w&~s;0<s;){var re=31-Vt(s),le=1<<re;A[re]=0,L[re]=-1;var X=K[re];if(X!==null)for(K[re]=null,re=0;re<X.length;re++){var ee=X[re];ee!==null&&(ee.lane&=-536870913)}s&=~le}u!==0&&Da(t,u,0),b!==0&&g===0&&t.tag!==0&&(t.suspendedLanes|=b&~(w&~r))}function Da(t,r,s){t.pendingLanes|=r,t.suspendedLanes&=~r;var u=31-Vt(r);t.entangledLanes|=r,t.entanglements[u]=t.entanglements[u]|1073741824|s&261930}function en(t,r){var s=t.entangledLanes|=r;for(t=t.entanglements;s;){var u=31-Vt(s),g=1<<u;g&r|t[u]&r&&(t[u]|=r),s&=~g}}function tn(t,r){var s=r&-r;return s=(s&42)!==0?1:So(s),(s&(t.suspendedLanes|r))!==0?0:s}function So(t){switch(t){case 2:t=1;break;case 8:t=4;break;case 32:t=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:t=128;break;case 268435456:t=134217728;break;default:t=0}return t}function Hn(t){return t&=-t,2<t?8<t?(t&134217727)!==0?32:268435456:8:2}function Dd(){var t=$.p;return t!==0?t:(t=window.event,t===void 0?32:jx(t.type))}function Bg(t,r){var s=$.p;try{return $.p=t,r()}finally{$.p=s}}var Yr=Math.random().toString(36).slice(2),zt="__reactFiber$"+Yr,nn="__reactProps$"+Yr,wo="__reactContainer$"+Yr,Ld="__reactEvents$"+Yr,X5="__reactListeners$"+Yr,Z5="__reactHandles$"+Yr,Ug="__reactResources$"+Yr,Ys="__reactMarker$"+Yr;function zd(t){delete t[zt],delete t[nn],delete t[Ld],delete t[X5],delete t[Z5]}function Co(t){var r=t[zt];if(r)return r;for(var s=t.parentNode;s;){if(r=s[wo]||s[zt]){if(s=r.alternate,r.child!==null||s!==null&&s.child!==null)for(t=lx(t);t!==null;){if(s=t[zt])return s;t=lx(t)}return r}t=s,s=t.parentNode}return null}function jo(t){if(t=t[zt]||t[wo]){var r=t.tag;if(r===5||r===6||r===13||r===31||r===26||r===27||r===3)return t}return null}function Ws(t){var r=t.tag;if(r===5||r===26||r===27||r===6)return t.stateNode;throw Error(o(33))}function Eo(t){var r=t[Ug];return r||(r=t[Ug]={hoistableStyles:new Map,hoistableScripts:new Map}),r}function At(t){t[Ys]=!0}var Hg=new Set,Vg={};function La(t,r){No(t,r),No(t+"Capture",r)}function No(t,r){for(Vg[t]=r,t=0;t<r.length;t++)Hg.add(r[t])}var Q5=RegExp("^[:A-Z_a-z\\u00C0-\\u00D6\\u00D8-\\u00F6\\u00F8-\\u02FF\\u0370-\\u037D\\u037F-\\u1FFF\\u200C-\\u20
Line truncated
|
||||
`).replace(RN,"")}function Qy(t,r){return r=Zy(r),Zy(t)===r}function rt(t,r,s,u,g,b){switch(s){case"children":typeof u=="string"?r==="body"||r==="textarea"&&u===""||To(t,u):(typeof u=="number"||typeof u=="bigint")&&r!=="body"&&To(t,""+u);break;case"className":Ml(t,"class",u);break;case"tabIndex":Ml(t,"tabindex",u);break;case"dir":case"role":case"viewBox":case"width":case"height":Ml(t,s,u);break;case"style":Qg(t,u,b);break;case"data":if(r!=="object"){Ml(t,"data",u);break}case"src":case"href":if(u===""&&(r!=="a"||s!=="href")){t.removeAttribute(s);break}if(u==null||typeof u=="function"||typeof u=="symbol"||typeof u=="boolean"){t.removeAttribute(s);break}u=Ol(""+u),t.setAttribute(s,u);break;case"action":case"formAction":if(typeof u=="function"){t.setAttribute(s,"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 b=="function"&&(s==="formAction"?(r!=="input"&&rt(t,r,"name",g.name,g,null),rt(t,r,"formEncType",g.formEncType,g,null),rt(t,r,"formMethod",g.formMethod,g,null),rt(t,r,"formTarget",g.formTarget,g,null)):(rt(t,r,"encType",g.encType,g,null),rt(t,r,"method",g.method,g,null),rt(t,r,"target",g.target,g,null)));if(u==null||typeof u=="symbol"||typeof u=="boolean"){t.removeAttribute(s);break}u=Ol(""+u),t.setAttribute(s,u);break;case"onClick":u!=null&&(t.onclick=hr);break;case"onScroll":u!=null&&Ie("scroll",t);break;case"onScrollEnd":u!=null&&Ie("scrollend",t);break;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(o(61));if(s=u.__html,s!=null){if(g.children!=null)throw Error(o(60));t.innerHTML=s}}break;case"multiple":t.multiple=u&&typeof u!="function"&&typeof u!="symbol";break;case"muted":t.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"){t.removeAttribute("xlink:href");break}s=Ol(""+u),t.setAttributeNS("http://www.w3.org/1999/xlink","xlink:href",s);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"?t.setAttribute(s,""+u):t.removeAttribute(s);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"?t.setAttribute(s,""):t.removeAttribute(s);break;case"capture":case"download":u===!0?t.setAttribute(s,""):u!==!1&&u!=null&&typeof u!="function"&&typeof u!="symbol"?t.setAttribute(s,u):t.removeAttribute(s);break;case"cols":case"rows":case"size":case"span":u!=null&&typeof u!="function"&&typeof u!="symbol"&&!isNaN(u)&&1<=u?t.setAttribute(s,u):t.removeAttribute(s);break;case"rowSpan":case"start":u==null||typeof u=="function"||typeof u=="symbol"||isNaN(u)?t.removeAttribute(s):t.setAttribute(s,u);break;case"popover":Ie("beforetoggle",t),Ie("toggle",t),Al(t,"popover",u);break;case"xlinkActuate":pr(t,"http://www.w3.org/1999/xlink","xlink:actuate",u);break;case"xlinkArcrole":pr(t,"http://www.w3.org/1999/xlink","xlink:arcrole",u);break;case"xlinkRole":pr(t,"http://www.w3.org/1999/xlink","xlink:role",u);break;case"xlinkShow":pr(t,"http://www.w3.org/1999/xlink","xlink:show",u);break;case"xlinkTitle":pr(t,"http://www.w3.org/1999/xlink","xlink:title",u);break;case"xlinkType":pr(t,"http://www.w3.org/1999/xlink","xlink:type",u);break;case"xmlBase":pr(t,"http://www.w3.org/XML/1998/namespace","xml:base",u);break;case"xmlLang":pr(t,"http://www.w3.org/XML/1998/namespace","xml:lang",u);break;case"xmlSpace":pr(t,"http://www.w3.org/XML/1998/namespace","xml:space",u);break;case"is":Al(t,"is",u);break;case"innerText":case"textContent":break;default:(!(2<s.length)||s[0]!=="o"&&s[0]!=="O"||s[1]!=="n"&&s[1]!=="N")&&(s=rE.get(s)||s,Al(t,s,u))}}function km(t,r,s,u,g,b){switch(s){case"style":Qg(t,u,b);break;case"dangerouslySetInnerHTML":if(u!=null){if(typeof u!="object"||!("__html"in u))throw Error(o(61));if(s=u.__html,s!=null){if(g.children!=null)throw Error(o(60));t.innerHTML=s}}break;case"children":typeof u=="string"?To(t,u):(typeof u=="number"||typeof u=="bigint")&&To(t,""+u);break;case"onScroll":u!=null&&Ie("scroll",t);break;case"onScrollEnd":u!=null&&Ie("scrollend",t);break;case"onClick":u!=null&&(t.onclick=hr);break;case"suppressContentEditableWarning":case"suppressHydrationWarning":case"inne
Line truncated
|
||||
* react-router v7.15.1
|
||||
*
|
||||
* 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 Hx="popstate";function Vx(e){return typeof e=="object"&&e!=null&&"pathname"in e&&"search"in e&&"hash"in e&&"state"in e&&"key"in e}function y3(e={}){function n(o,i){var h;let c=(h=i.state)==null?void 0:h.masked,{pathname:d,search:m,hash:p}=c||o.location;return Op("",{pathname:d,search:m,hash:p},i.state&&i.state.usr||null,i.state&&i.state.key||"default",c?{pathname:o.location.pathname,search:o.location.search,hash:o.location.hash}:void 0)}function a(o,i){return typeof i=="string"?i:Xi(i)}return S3(n,a,null,e)}function ft(e,n){if(e===!1||e===null||typeof e>"u")throw new Error(n)}function $n(e,n){if(!e){typeof console<"u"&&console.warn(n);try{throw new Error(n)}catch{}}}function x3(){return Math.random().toString(36).substring(2,10)}function Gx(e,n){return{usr:e.state,key:e.key,idx:n,masked:e.mask?{pathname:e.pathname,search:e.search,hash:e.hash}:void 0}}function Op(e,n,a=null,o,i){return{pathname:typeof e=="string"?e:e.pathname,search:"",hash:"",...typeof n=="string"?Rs(n):n,state:a,key:n&&n.key||o||x3(),mask:i}}function Xi({pathname:e="/",search:n="",hash:a=""}){return n&&n!=="?"&&(e+=n.charAt(0)==="?"?n:"?"+n),a&&a!=="#"&&(e+=a.charAt(0)==="#"?a:"#"+a),e}function Rs(e){let n={};if(e){let a=e.indexOf("#");a>=0&&(n.hash=e.substring(a),e=e.substring(0,a));let o=e.indexOf("?");o>=0&&(n.search=e.substring(o),e=e.substring(0,o)),e&&(n.pathname=e)}return n}function S3(e,n,a,o={}){let{window:i=document.defaultView,v5Compat:c=!1}=o,d=i.history,m="POP",p=null,h=y();h==null&&(h=0,d.replaceState({...d.state,idx:h},""));function y(){return(d.state||{idx:null}).idx}function v(){m="POP";let N=y(),E=N==null?null:N-h;h=N,p&&p({action:m,location:C.location,delta:E})}function x(N,E){m="PUSH";let k=Vx(N)?N:Op(C.location,N,E);h=y()+1;let T=Gx(k,h),R=C.createHref(k.mask||k);try{d.pushState(T,"",R)}catch(D){if(D instanceof DOMException&&D.name==="DataCloneError")throw D;i.location.assign(R)}c&&p&&p({action:m,location:C.location,delta:1})}function S(N,E){m="REPLACE";let k=Vx(N)?N:Op(C.location,N,E);h=y();let T=Gx(k,h),R=C.createHref(k.mask||k);d.replaceState(T,"",R),c&&p&&p({action:m,location:C.location,delta:0})}function j(N){return w3(N)}let C={get action(){return m},get location(){return e(i,d)},listen(N){if(p)throw new Error("A history only accepts one active listener");return i.addEventListener(Hx,v),p=N,()=>{i.removeEventListener(Hx,v),p=null}},createHref(N){return n(i,N)},createURL:j,encodeLocation(N){let E=j(N);return{pathname:E.pathname,search:E.search,hash:E.hash}},push:x,replace:S,go(N){return d.go(N)}};return C}function w3(e,n=!1){let a="http://localhost";typeof window<"u"&&(a=window.location.origin!=="null"?window.location.origin:window.location.href),ft(a,"No window.location.(origin|href) available to create URL");let o=typeof e=="string"?e:Xi(e);return o=o.replace(/ $/,"%20"),!n&&o.startsWith("//")&&(o=a+o),new URL(o,a)}function j0(e,n,a="/"){return C3(e,n,a,!1)}function C3(e,n,a,o,i){let c=typeof n=="string"?Rs(n):n,d=zr(c.pathname||"/",a);if(d==null)return null;let m=j3(e),p=null,h=z3(d);for(let y=0;p==null&&y<m.length;++y)p=D3(m[y],h,o);return p}function j3(e){let n=E0(e);return E3(n),n}function E0(e,n=[],a=[],o="",i=!1){let c=(d,m,p=i,h)=>{let y={relativePath:h===void 0?d.path||"":h,caseSensitive:d.caseSensitive===!0,childrenIndex:m,route:d};if(y.relativePath.startsWith("/")){if(!y.relativePath.startsWith(o)&&p)return;ft(y.relativePath.startsWith(o),`Absolute route path "${y.relativePath}" nested under path "${o}" is not valid. An absolute child route path must start with the combined path of all its parent routes.`),y.relativePath=y.relativePath.slice(o.length)}let v=Wn([o,y.relativePath]),x=a.concat(y);d.children&&d.children.length>0&&(ft(d.index!==!0,`Index routes must not have child routes. Please remove all child routes from route path "${v}".`),E0(d.children,n,x,v,p)),!(d.path==null&&!d.index)&&n.push({path:v,score:_3(v,d.index),routesMeta:x})};return e.forEach((d,m)=>{var p;if(d.path===""||!((p=d.path)!=null&&p.includes("?")))c(d,m);else for(let h of N0(d.path))c(d,m,!0,h)}),n}function N0(e){let n=e.split("/");if(n.length===0)return[];let[a,...o]=n,i=a.endsWith("?"),c=a.replace(/\?$/,"");if(o.length===0)return i?[c,""]:[c];let d=N0(o.join("/")),m=[];return m.push(...d.map(p=>p===""?c:[c,p].join("/"))),i&&m.push(...d),m.map(p=>e.startsWith("/")&&p===""?"/":p)}function E3(e){e.sort((n,a)=>n.score!==a.score?a.score-n.score:O3(n.routesMeta.map(o=>o.childrenIndex),a.routesMeta.map(o=>o.childrenIndex)))}var N3=/^:[\w-]+$/,k3=3,T3=2,R3=1,A3=10,M3=-2,Fx=e=>e==="*";function _3(e,n){let a=e.split("/"),o=a.length;return a.some(Fx)&&(o+=M3),n&&(o+=T3),a.filter(i=>!Fx(i)).reduce((i,c)=>i+(N3.test(c)?k3:c===""?R3:A3),o)}function O3(e,n){return e.length===n.length&&e.slice(0,-1).every((o,i)=>o===n[i])?e[e.length-1]-n[n.length-1]:0}function D3(e,n,a=!1){let{routesMeta:o}=e,i={},c="/",d=[];for(let m=0;m<o.length;++m){let p=o[m],h=m===o.length-1,y=c==="/"?n:n.slice(c.length)||"/",v=pu({path:p.relativePath,caseSensit
Line truncated
|
||||
|
||||
Please change the parent <Route path="${E}"> to <Route path="${E==="/"?"*":`${E}/*`}">.`)}let y=jn(),v;if(n){let E=typeof n=="string"?Rs(n):n;ft(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=y;let x=v.pathname||"/",S=x;if(p!=="/"){let E=p.replace(/^\//,"").split("/");S="/"+x.replace(/^\//,"").split("/").slice(E.length).join("/")}let j=a&&a.state.matches.length?a.state.matches.map(E=>Object.assign(E,{route:a.manifest[E.route.id]||E.route})):j0(e,{pathname:S});$n(h||j!=null,`No routes matched location "${v.pathname}${v.search}${v.hash}" `),$n(j==null||j[j.length-1].route.element!==void 0||j[j.length-1].route.Component!==void 0||j[j.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 C=c4(j&&j.map(E=>Object.assign({},E,{params:Object.assign({},d,E.params),pathname:Wn([p,o.encodeLocation?o.encodeLocation(E.pathname.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:E.pathname]),pathnameBase:E.pathnameBase==="/"?p:Wn([p,o.encodeLocation?o.encodeLocation(E.pathnameBase.replace(/%/g,"%25").replace(/\?/g,"%3F").replace(/#/g,"%23")).pathname:E.pathnameBase])})),i,a);return n&&C?f.createElement(cl.Provider,{value:{location:{pathname:"/",search:"",hash:"",state:null,key:"default",mask:void 0,...v},navigationType:"POP"}},C):C}function o4(){let e=p4(),n=G3(e)?`${e.status} ${e.statusText}`:e instanceof Error?e.message:JSON.stringify(e),a=e instanceof Error?e.stack:null,o="rgba(200,200,200, 0.5)",i={padding:"0.5rem",backgroundColor:o},c={padding:"2px 4px",backgroundColor:o},d=null;return console.error("Error handled by React Router default ErrorBoundary:",e),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:c},"ErrorBoundary")," or"," ",f.createElement("code",{style:c},"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:i},a):null,d)}var s4=f.createElement(o4,null),P0=class extends f.Component{constructor(e){super(e),this.state={location:e.location,revalidation:e.revalidation,error:e.error}}static getDerivedStateFromError(e){return{error:e}}static getDerivedStateFromProps(e,n){return n.location!==e.location||n.revalidation!=="idle"&&e.revalidation==="idle"?{error:e.error,location:e.location,revalidation:e.revalidation}:{error:e.error!==void 0?e.error:n.error,location:n.location,revalidation:e.revalidation||n.revalidation}}componentDidCatch(e,n){this.props.onError?this.props.onError(e,n):console.error("React Router caught the following error during render",e)}render(){let e=this.state.error;if(this.context&&typeof e=="object"&&e&&"digest"in e&&typeof e.digest=="string"){const a=J3(e.digest);a&&(e=a)}let n=e!==void 0?f.createElement(Xn.Provider,{value:this.props.routeContext},f.createElement(jh.Provider,{value:e,children:this.props.component})):this.props.children;return this.context?f.createElement(i4,{error:e},n):n}};P0.contextType=M0;var Qm=new WeakMap;function i4({children:e,error:n}){let{basename:a}=f.useContext(Cn);if(typeof n=="object"&&n&&"digest"in n&&typeof n.digest=="string"){let o=Q3(n.digest);if(o){let i=Qm.get(n);if(i)throw i;let c=R0(o.location,a);if(T0&&!Qm.get(n))if(c.isExternal||o.reloadDocument)window.location.href=c.absoluteURL||c.to;else{const d=Promise.resolve().then(()=>window.__reactRouterDataRouter.navigate(c.to,{replace:o.replace}));throw Qm.set(n,d),d}return f.createElement("meta",{httpEquiv:"refresh",content:`0;url=${c.absoluteURL||c.to}`})}}return e}function l4({routeContext:e,match:n,children:a}){let o=f.useContext(As);return o&&o.static&&o.staticContext&&(n.route.errorElement||n.route.ErrorBoundary)&&(o.staticContext._deepestRenderedBoundaryId=n.route.id),f.createElement(Xn.Provider,{value:e},a)}function c4(e,n=[],a){let o=a==null?void 0:a.state;if(e==null){if(!o)return null;if(o.errors)e=o.matches;else if(n.length===0&&!o.initialized&&o.matches.length>0)e=o.matches;else return null}let i=e,c=o==null?void 0:o.errors;if(c!=null){let y=i.findIndex(v=>v.route.id&&(c==null?void 0:c[v.route.id])!==void 0);ft(y>=0,`Could not find a matching route for errors on route IDs: ${Object.keys(c).join(",")}`),i=i.slice(0,Math.min(i.length,y+1))}let d=!1,m=-1;if(a&&o){d=o.renderFallback;for(let y=0;y<i.length;y++){let v=i[y];if((v.route.HyLine truncated
|
||||
.`.concat(U9,` {
|
||||
overflow: hidden `).concat(o,`;
|
||||
padding-right: `).concat(m,"px ").concat(o,`;
|
||||
}
|
||||
body[`).concat(hs,`] {
|
||||
overflow: hidden `).concat(o,`;
|
||||
overscroll-behavior: contain;
|
||||
`).concat([n&&"position: relative ".concat(o,";"),a==="margin"&&`
|
||||
padding-left: `.concat(i,`px;
|
||||
padding-top: `).concat(c,`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(lu,` {
|
||||
right: `).concat(m,"px ").concat(o,`;
|
||||
}
|
||||
|
||||
.`).concat(cu,` {
|
||||
margin-right: `).concat(m,"px ").concat(o,`;
|
||||
}
|
||||
|
||||
.`).concat(lu," .").concat(lu,` {
|
||||
right: 0 `).concat(o,`;
|
||||
}
|
||||
|
||||
.`).concat(cu," .").concat(cu,` {
|
||||
margin-right: 0 `).concat(o,`;
|
||||
}
|
||||
|
||||
body[`).concat(hs,`] {
|
||||
`).concat(H9,": ").concat(m,`px;
|
||||
}
|
||||
`)},T1=function(){var e=parseInt(document.body.getAttribute(hs)||"0",10);return isFinite(e)?e:0},iR=function(){f.useEffect(function(){return document.body.setAttribute(hs,(T1()+1).toString()),function(){var e=T1()-1;e<=0?document.body.removeAttribute(hs):document.body.setAttribute(hs,e.toString())}},[])},lR=function(e){var n=e.noRelative,a=e.noImportant,o=e.gapMode,i=o===void 0?"margin":o;iR();var c=f.useMemo(function(){return aR(i)},[i]);return f.createElement(oR,{styles:sR(c,!n,i,a?"":"!important")})},Fp=!1;if(typeof window<"u")try{var Kc=Object.defineProperty({},"passive",{get:function(){return Fp=!0,!0}});window.addEventListener("test",Kc,Kc),window.removeEventListener("test",Kc,Kc)}catch{Fp=!1}var is=Fp?{passive:!1}:!1,cR=function(e){return e.tagName==="TEXTAREA"},cS=function(e,n){if(!(e instanceof Element))return!1;var a=window.getComputedStyle(e);return a[n]!=="hidden"&&!(a.overflowY===a.overflowX&&!cR(e)&&a[n]==="visible")},uR=function(e){return cS(e,"overflowY")},dR=function(e){return cS(e,"overflowX")},R1=function(e,n){var a=n.ownerDocument,o=n;do{typeof ShadowRoot<"u"&&o instanceof ShadowRoot&&(o=o.host);var i=uS(e,o);if(i){var c=dS(e,o),d=c[1],m=c[2];if(d>m)return!0}o=o.parentNode}while(o&&o!==a.body);return!1},fR=function(e){var n=e.scrollTop,a=e.scrollHeight,o=e.clientHeight;return[n,a,o]},mR=function(e){var n=e.scrollLeft,a=e.scrollWidth,o=e.clientWidth;return[n,a,o]},uS=function(e,n){return e==="v"?uR(n):dR(n)},dS=function(e,n){return e==="v"?fR(n):mR(n)},pR=function(e,n){return e==="h"&&n==="rtl"?-1:1},hR=function(e,n,a,o,i){var c=pR(e,window.getComputedStyle(n).direction),d=c*o,m=a.target,p=n.contains(m),h=!1,y=d>0,v=0,x=0;do{if(!m)break;var S=dS(e,m),j=S[0],C=S[1],N=S[2],E=C-N-c*j;(j||E)&&uS(e,m)&&(v+=E,x+=j);var k=m.parentNode;m=k&&k.nodeType===Node.DOCUMENT_FRAGMENT_NODE?k.host:k}while(!p&&m!==document.body||p&&(n.contains(m)||n===m));return(y&&Math.abs(v)<1||!y&&Math.abs(x)<1)&&(h=!0),h},Xc=function(e){return"changedTouches"in e?[e.changedTouches[0].clientX,e.changedTouches[0].clientY]:[0,0]},A1=function(e){return[e.deltaX,e.deltaY]},M1=function(e){return e&&"current"in e?e.current:e},gR=function(e,n){return e[0]===n[0]&&e[1]===n[1]},vR=function(e){return`
|
||||
.block-interactivity-`.concat(e,` {pointer-events: none;}
|
||||
.allow-interactivity-`).concat(e,` {pointer-events: all;}
|
||||
`)},bR=0,ls=[];function yR(e){var n=f.useRef([]),a=f.useRef([0,0]),o=f.useRef(),i=f.useState(bR++)[0],c=f.useState(lS)[0],d=f.useRef(e);f.useEffect(function(){d.current=e},[e]),f.useEffect(function(){if(e.inert){document.body.classList.add("block-interactivity-".concat(i));var C=B9([e.lockRef.current],(e.shards||[]).map(M1),!0).filter(Boolean);return C.forEach(function(N){return N.classList.add("allow-interactivity-".concat(i))}),function(){document.body.classList.remove("block-interactivity-".concat(i)),C.forEach(function(N){return N.classList.remove("allow-interactivity-".concat(i))})}}},[e.inert,e.lockRef.current,e.shards]);var m=f.useCallback(function(C,N){if("touches"in C&&C.touches.length===2||C.type==="wheel"&&C.ctrlKey)return!d.current.allowPinchZoom;var E=Xc(C),k=a.current,T="deltaX"in C?C.deltaX:k[0]-E[0],R="deltaY"in C?C.deltaY:k[1]-E[1],D,P=C.target,z=Math.abs(T)>Math.abs(R)?"h":"v";if("touches"in C&&z==="h"&&P.type==="range")return!1;var B=window.getSelection(),H=B&&B.anchorNode,Y=H?H===P||H.contains(P):!1;if(Y)return!1;var Q=R1(z,P);if(!Q)return!0;if(Q?D=z:(D=z==="v"?"h":"v",Q=R1(z,P)),!Q)return!1;if(!o.current&&"changedTouches"in C&&(T||R)&&(o.current=D),!D)return!0;var fe=o.current||D;return hR(fe,N,C,fe==="h"?T:R)},[]),p=f.useCallback(function(C){var N=C;if(!(!ls.length||ls[ls.length-1]!==c)){var E="deltaY"in N?A1(N):Xc(N),k=n.current.filter(function(D){return D.name===N.type&&(D.target===N.target||N.target===D.shadowParent)&&gR(D.delta,E)})[0];if(k&&k.should){N.cancelable&&N.preventDefault();return}if(!k){var T=(d.current.shards||[]).map(M1).filter(Boolean).filter(function(D){return D.contains(N.target)}),R=T.length>0?m(N,T[0]):!d.current.noIsolation;R&&N.cancelable&&N.preventDefault()}}},[]),h=f.useCallback(function(C,N,E,k){var T={name:C,delta:N,target:E,should:k,shadowParent:xR(E)};n.current.push(T),setTimeout(function(){n.current=n.current.filter(function(R){return R!==T})},1)},[]),y=f.useCallback(function(C){a.current=Xc(C),o.current=void 0},[]),v=f.useCallback(function(C){h(C.type,A1(C),C.target,m(C,e.lockRef.current))},[]),x=f.useCallback(function(C){h(C.type,Xc(C),C.target,m(C,e.lockRef.current))},[]);f.useEffect(function(){return ls.push(c),e.setCallbacks({onScrollCapture:v,onWheelCapture:v,onTouchMoveCapture:x}),document.addEventListener("wheel",p,is),document.addEventListener("touchmove",p,is),document.addEventListener("touchstart",y,is),function(){ls=ls.filter(function(C){return C!==c}),document.removeEventListener("wheel",p,is),document.removeEventListener("touchmove",p,is),document.removeEventListener("touchstart",y,is)}},[]);var S=e.removeScrollBar,j=e.inert;return f.createElement(f.Fragment,null,j?f.createElement(c,{styles:vR(i)}):null,S?f.createElement(lR,{noRelative:e.noRelative,gapMode:e.gapMode}):null)}function xR(e){for(var n=null;e!==null;)e instanceof ShadowRoot&&(n=e.host,e=e.host),e=e.parentNode;return n}const SR=K9(iS,yR);var vl=f.forwardRef(function(e,n){return f.createElement(rd,ir({},e,{ref:n,sideCar:SR}))});vl.classNames=rd.classNames;var Yp=["Enter"," "],wR=["ArrowDown","PageUp","Home"],fS=["ArrowUp","PageDown","End"],CR=[...wR,...fS],jR={ltr:[...Yp,"ArrowRight"],rtl:[...Yp,"ArrowLeft"]},ER={ltr:["ArrowLeft"],rtl:["ArrowRight"]},bl="Menu",[tl,NR,kR]=Vh(bl),[go,mS]=Hk(bl,[kR,Ea,td]),ad=Ea(),pS=td(),[TR,vo]=go(bl),[RR,yl]=go(bl),hS=e=>{const{__scopeMenu:n,open:a=!1,children:o,dir:i,onOpenChange:c,modal:d=!0}=e,m=ad(n),[p,h]=f.useState(null),y=f.useRef(!1),v=Ot(c),x=dl(i);return f.useEffect(()=>{const S=()=>{y.current=!0,document.addEventListener("pointerdown",j,{capture:!0,once:!0}),document.addEventListener("pointermove",j,{capture:!0,once:!0})},j=()=>y.current=!1;return document.addEventListener("keydown",S,{capture:!0}),()=>{document.removeEventListener("keydown",S,{capture:!0}),document.removeEventListener("pointerdown",j,{capture:!0}),document.removeEventListener("pointermove",j,{capture:!0})}},[]),l.jsx(Qu,{...m,children:l.jsx(TR,{scope:n,open:a,onOpenChange:v,content:p,onContentChange:h,children:l.jsx(RR,{scope:n,onClose:f.useCallback(()=>v(!1),[v]),isUsingKeyboardRef:y,dir:x,modal:d,children:o})})})};hS.displayName=bl;var AR="MenuAnchor",Qh=f.forwardRef((e,n)=>{const{__scopeMenu:a,...o}=e,i=ad(a);return l.jsx(pl,{...i,...o,ref:n})});Qh.displayName=AR;var Jh="MenuPortal",[MR,gS]=go(Jh,{forceMount:void 0}),vS=e=>{const{__scopeMenu:n,forceMount:a,children:o,container:i}=e,c=vo(Jh,n);return l.jsx(MR,{scope:n,forceMount:a,children:l.jsx(Ht,{present:a||c.open,children:l.jsx(Is,{asChild:!0,container:i,children:o})})})};vS.displayName=Jh;var In="MenuContent",[_R,eg]=go(In),bS=f.forwardRef((e,n)=>{const a=gS(In,e.__scopeMenu),{forceMount:o=a.forceMount,...i}=e,c=vo(In,e.__scopeMenu),d=yl(In,e.__scopeMenu);return l.jsx(tl.Provider,{scope:e.__scopeMenu,children:l.jsx(Ht,{present:o||c.open,children:l.jsx(tl.Slot,{scope:e.__scopeMenu,children:d.modal?l.jsx(OR,{...i,ref:n}):l.jsx(DR,{...i,ref:n})})})})}),OR=f.forwardRef((e,n)=>{const a=vo(In,e.__scopeMenu),o=f.useRe
Line truncated
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const ow=(...e)=>e.filter((n,a,o)=>!!n&&n.trim()!==""&&o.indexOf(n)===a).join(" ").trim();/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const MA=e=>e.replace(/([a-z0-9])([A-Z])/g,"$1-$2").toLowerCase();/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const _A=e=>e.replace(/^([A-Z])|[\s-_]+(\w)/g,(n,a,o)=>o?o.toUpperCase():a.toLowerCase());/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const O1=e=>{const n=_A(e);return n.charAt(0).toUpperCase()+n.slice(1)};/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/var pp={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 v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const OA=e=>{for(const n in e)if(n.startsWith("aria-")||n==="role"||n==="title")return!0;return!1},DA=f.createContext({}),LA=()=>f.useContext(DA),zA=f.forwardRef(({color:e,size:n,strokeWidth:a,absoluteStrokeWidth:o,className:i="",children:c,iconNode:d,...m},p)=>{const{size:h=24,strokeWidth:y=2,absoluteStrokeWidth:v=!1,color:x="currentColor",className:S=""}=LA()??{},j=o??v?Number(a??y)*24/Number(n??h):a??y;return f.createElement("svg",{ref:p,...pp,width:n??h??pp.width,height:n??h??pp.height,stroke:e??x,strokeWidth:j,className:ow("lucide",S,i),...!c&&!OA(m)&&{"aria-hidden":"true"},...m},[...d.map(([C,N])=>f.createElement(C,N)),...Array.isArray(c)?c:[c]])});/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const Ur=(e,n)=>{const a=f.forwardRef(({className:o,...i},c)=>f.createElement(zA,{ref:c,iconNode:n,className:ow(`lucide-${MA(O1(e))}`,`lucide-${e}`,o),...i}));return a.displayName=O1(e),a};/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const PA=[["path",{d:"M20 6 9 17l-5-5",key:"1gmf2c"}]],sw=Ur("check",PA);/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const IA=[["path",{d:"m6 9 6 6 6-6",key:"qrunsl"}]],iw=Ur("chevron-down",IA);/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const $A=[["path",{d:"m9 18 6-6-6-6",key:"mthhwq"}]],BA=Ur("chevron-right",$A);/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const UA=[["path",{d:"m18 15-6-6-6 6",key:"153udz"}]],HA=Ur("chevron-up",UA);/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const VA=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}],["path",{d:"m9 12 2 2 4-4",key:"dzmm74"}]],D1=Ur("circle-check",VA);/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const GA=[["circle",{cx:"12",cy:"12",r:"10",key:"1mglay"}]],FA=Ur("circle",GA);/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const YA=[["rect",{width:"18",height:"18",x:"3",y:"3",rx:"2",key:"afitv7"}],["path",{d:"M9 3v18",key:"fh3hqa"}]],WA=Ur("panel-left",YA);/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const qA=[["path",{d:"m21 21-4.34-4.34",key:"14j7rj"}],["circle",{cx:"11",cy:"11",r:"8",key:"4ej97u"}]],KA=Ur("search",qA);/**
|
||||
* @license lucide-react v1.16.0 - ISC
|
||||
*
|
||||
* This source code is licensed under the ISC license.
|
||||
* See the LICENSE file in the root directory of this source tree.
|
||||
*/const XA=[["path",{d:"M18 6 6 18",key:"1bl5f8"}],["path",{d:"m6 6 12 12",key:"d8bk6v"}]],lw=Ur("x",XA),og=TA,sg=RA,ZA=f.forwardRef(({className:e,inset:n,children:a,...o},i)=>l.jsxs(rw,{ref:i,className:ce("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",e),...o,children:[a,l.jsx(BA,{className:"ml-auto"})]}));ZA.displayName=rw.displayName;const QA=f.forwardRef(({className:e,...n},a)=>l.jsx(aw,{ref:a,className:ce("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]",e),...n}));QA.displayName=aw.displayName;const id=f.forwardRef(({className:e,sideOffset:n=4,...a},o)=>l.jsx(AA,{children:l.jsx(XS,{ref:o,sideOffset:n,className:ce("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]",e),...a})}));id.displayName=XS.displayName;const rl=f.forwardRef(({className:e,inset:n,...a},o)=>l.jsx(QS,{ref:o,className:ce("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",e),...a}));rl.displayName=QS.displayName;const JA=f.forwardRef(({className:e,children:n,checked:a,...o},i)=>l.jsxs(JS,{ref:i,className:ce("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",e),checked:a,...o,children:[l.jsx("span",{className:"absolute left-2 flex h-3.5 w-3.5 items-center justify-center",children:l.jsx(tw,{children:l.jsx(sw,{className:"h-4 w-4"})})}),n]}));JA.displayName=JS.displayName;const e7=f.forwardRef(({className:e,children:n,...a},o)=>l.jsxs(ew,{ref:o,className:ce("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",e),...a,children:[l.jsx("span",{className:"absolute left-2 flex h-3.5 w-3.5 items-center justify-center",children:l.jsx(tw,{children:l.jsx(FA,{className:"h-2 w-2 fill-current"})})}),n]}));e7.displayName=ew.displayName;const ig=f.forwardRef(({className:e,inset:n,...a},o)=>l.jsx(ZS,{ref:o,className:ce("px-2 py-1.5 text-sm font-semibold",n&&"pl-8",e),...a}));ig.displayName=ZS.displayName;const Nu=f.forwardRef(({className:e,...n},a)=>l.jsx(nw,{ref:a,className:ce("-mx-1 my-1 h-px bg-muted",e),...n}));Nu.displayName=nw.displayName;const hp=768;function t7(){const[e,n]=f.useState(void 0);return f.useEffect(()=>{const a=window.matchMedia(`(max-width: ${hp-1}px)`),o=()=>{n(window.innerWidth<hp)};return a.addEventListener("change",o),n(window.innerWidth<hp),()=>a.removeEventListener("change",o)},[]),!!e}const Ue=f.forwardRef(({className:e,type:n,...a},o)=>l.jsx("input",{type:n,className:ce("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",e),ref:o,...a}));Ue.displayName="Input";var n7=["a","button","div","form","h2","h3","img","input","label","li","nav","ol","p","select","span","svg","ul"],En=n7.reduce((e,n)=>{const a=S2(`Primitive.${n}`),o=f.forwardRef((i,c)=>{const{asChild:d,...m}=i,p=d?a:n;return typeof window<"u"&&(window[Symbol.for("radix-ui")]=!0),l.jsx(p,{...m,ref:c})});return o.displayName=`Primitive.${n}`,{...e,[n]:o}},{}),r7="Separator",L1="horizontal",a7=["horizontal","vertical"],cw=f.forwardRef((e,n)=>{const{decorative:a,orientation:o=L1,...i}=e,c=o7(o)?o:L1,m=a?{role:"none"}:{"aria-orientation":c==="vertical"?c:void 0,role:"separator"};returnLine truncated
|
||||
|
||||
If you want to hide the \`${n.titleName}\`, you can wrap it with our VisuallyHidden component.
|
||||
|
||||
For more information, see https://radix-ui.com/primitives/docs/components/${n.docsSlug}`;return f.useEffect(()=>{e&&(document.getElementById(e)||console.error(a))},[a,e]),null},w7="DialogDescriptionWarning",C7=({contentRef:e,descriptionId:n})=>{const o=`Warning: Missing \`Description\` or \`aria-describedby={undefined}\` for {${Tw(w7).contentName}}.`;return f.useEffect(()=>{var c;const i=(c=e.current)==null?void 0:c.getAttribute("aria-describedby");n&&i&&(document.getElementById(n)||console.warn(o))},[o,e,n]),null},dg=pw,j7=gw,fg=bw,Sl=yw,wl=xw,cd=ww,ud=jw,Rw=Nw;const E7=dg,N7=fg,Aw=f.forwardRef(({className:e,...n},a)=>l.jsx(Sl,{className:ce("fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",e),...n,ref:a}));Aw.displayName=Sl.displayName;const k7=Ds("fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out",{variants:{side:{top:"inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",bottom:"inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",left:"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",right:"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm"}},defaultVariants:{side:"right"}}),Mw=f.forwardRef(({side:e="right",className:n,children:a,...o},i)=>l.jsxs(N7,{children:[l.jsx(Aw,{}),l.jsxs(wl,{ref:i,className:ce(k7({side:e}),n),...o,children:[l.jsxs(Rw,{className:"absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary",children:[l.jsx(lw,{className:"h-4 w-4"}),l.jsx("span",{className:"sr-only",children:"Close"})]}),a]})]}));Mw.displayName=wl.displayName;const _w=({className:e,...n})=>l.jsx("div",{className:ce("flex flex-col space-y-2 text-center sm:text-left",e),...n});_w.displayName="SheetHeader";const Ow=f.forwardRef(({className:e,...n},a)=>l.jsx(cd,{ref:a,className:ce("text-lg font-semibold text-foreground",e),...n}));Ow.displayName=cd.displayName;const Dw=f.forwardRef(({className:e,...n},a)=>l.jsx(ud,{ref:a,className:ce("text-sm text-muted-foreground",e),...n}));Dw.displayName=ud.displayName;function Ve({className:e,...n}){return l.jsx("div",{className:ce("animate-pulse rounded-md bg-primary/10",e),...n})}function T7(e,n=[]){let a=[];function o(c,d){const m=f.createContext(d),p=a.length;a=[...a,d];const h=v=>{var E;const{scope:x,children:S,...j}=v,C=((E=x==null?void 0:x[e])==null?void 0:E[p])||m,N=f.useMemo(()=>j,Object.values(j));return l.jsx(C.Provider,{value:N,children:S})};h.displayName=c+"Provider";function y(v,x){var C;const S=((C=x==null?void 0:x[e])==null?void 0:C[p])||m,j=f.useContext(S);if(j)return j;if(d!==void 0)return d;throw new Error(`\`${v}\` must be used within \`${c}\``)}return[h,y]}const i=()=>{const c=a.map(d=>f.createContext(d));return function(m){const p=(m==null?void 0:m[e])||c;return f.useMemo(()=>({[`__scope${e}`]:{...m,[e]:p}}),[m,p])}};return i.scopeName=e,[o,R7(i,...n)]}function R7(...e){const n=e[0];if(e.length===1)return n;const a=()=>{const o=e.map(i=>({useScope:i(),scopeName:i.scopeName}));return function(c){const d=o.reduce((m,{useScope:p,scopeName:h})=>{const v=p(c)[`__scope${h}`];return{...m,...v}},{});return f.useMemo(()=>({[`__scope${n.scopeName}`]:d}),[d])}};return a.scopeName=n.scopeName,a}function A7(e){const n=M7(e),a=f.forwardRef((o,i)=>{const{children:c,...d}=o,m=f.Children.toArray(c),p=m.find(O7);if(p){const h=p.props.children,y=m.map(v=>v===p?f.Children.count(h)>1?f.Children.only(null):f.isValidElement(h)?h.props.children:null:v);return l.jsx(n,{...d,ref:i,children:f.isValidElement(h)?f.cloneElement(h,void 0,y):null})}return l.jsx(n,{...d,ref:i,children:c})});return a.displayName=`${e}.Slot`,a}function M7(e){const n=f.forwardRef((a,o)=>{const{children:i,...c}=a;if(f.isValidElement(i)){const d=L7(i),m=D7(c,i.props);return i.type!==f.Fragment&&(m.ref=o?st(o,d):d),f.cloneElement(i,m)}return f.Children.count(i)>1?f.Children.only(null):null});return n.displayName=`${e}.SlotClone`,n}var Lw=Symbol("radix.slottable");function _7(e){const n=({children:a})=>l.jsx(l.Fragment,{children:a});return n.displayName=`${e}.Slottable`,n.__radixId=Lw,n}function O7(e){return f.isValidElement(e)&&typeof e.type=="function"&&"__radixId"in e.type&&e.type.__radixId===Lw}function D7(e,n){const a={...n};for(const o in n){const i=e[o],c=n[o];/^on[A-Z]/.test(o)?i&&c?a[o]=(...m)=>{const p=c(...m);return i(...m),p}:i&&(a[o]=i):o==="style"?a[o]={...i,...c}:o==="className"&&(a[o]=[i,c].filter(Boolean).join(" "))}return{...e,...a}
Line truncated
|
||||
* @license React
|
||||
* use-sync-external-store-shim.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 B1;function P_(){if(B1)return xp;B1=1;var e=Lu();function n(v,x){return v===x&&(v!==0||1/v===1/x)||v!==v&&x!==x}var a=typeof Object.is=="function"?Object.is:n,o=e.useState,i=e.useEffect,c=e.useLayoutEffect,d=e.useDebugValue;function m(v,x){var S=x(),j=o({inst:{value:S,getSnapshot:x}}),C=j[0].inst,N=j[1];return c(function(){C.value=S,C.getSnapshot=x,p(C)&&N({inst:C})},[v,S,x]),i(function(){return p(C)&&N({inst:C}),v(function(){p(C)&&N({inst:C})})},[v]),d(S),S}function p(v){var x=v.getSnapshot;v=v.value;try{var S=x();return!a(v,S)}catch{return!0}}function h(v,x){return x()}var y=typeof window>"u"||typeof window.document>"u"||typeof window.document.createElement>"u"?h:m;return xp.useSyncExternalStore=e.useSyncExternalStore!==void 0?e.useSyncExternalStore:y,xp}var U1;function I_(){return U1||(U1=1,yp.exports=P_()),yp.exports}var $_=I_();function B_(){return $_.useSyncExternalStore(U_,()=>!0,()=>!1)}function U_(){return()=>{}}var bg="Avatar",[H_]=L_(bg),[V_,bC]=H_(bg),yC=f.forwardRef((e,n)=>{const{__scopeAvatar:a,...o}=e,[i,c]=f.useState("idle");return l.jsx(V_,{scope:a,imageLoadingStatus:i,onImageLoadingStatusChange:c,children:l.jsx(En.span,{...o,ref:n})})});yC.displayName=bg;var xC="AvatarImage",SC=f.forwardRef((e,n)=>{const{__scopeAvatar:a,src:o,onLoadingStatusChange:i=()=>{},...c}=e,d=bC(xC,a),m=G_(o,c),p=Ot(h=>{i(h),d.onImageLoadingStatusChange(h)});return jt(()=>{m!=="idle"&&p(m)},[m,p]),m==="loaded"?l.jsx(En.img,{...c,ref:n,src:o}):null});SC.displayName=xC;var wC="AvatarFallback",CC=f.forwardRef((e,n)=>{const{__scopeAvatar:a,delayMs:o,...i}=e,c=bC(wC,a),[d,m]=f.useState(o===void 0);return f.useEffect(()=>{if(o!==void 0){const p=window.setTimeout(()=>m(!0),o);return()=>window.clearTimeout(p)}},[o]),d&&c.imageLoadingStatus!=="loaded"?l.jsx(En.span,{...i,ref:n}):null});CC.displayName=wC;function H1(e,n){return e?n?(e.src!==n&&(e.src=n),e.complete&&e.naturalWidth>0?"loaded":"loading"):"error":"idle"}function G_(e,{referrerPolicy:n,crossOrigin:a}){const o=B_(),i=f.useRef(null),c=o?(i.current||(i.current=new window.Image),i.current):null,[d,m]=f.useState(()=>H1(c,e));return jt(()=>{m(H1(c,e))},[c,e]),jt(()=>{const p=v=>()=>{m(v)};if(!c)return;const h=p("loaded"),y=p("error");return c.addEventListener("load",h),c.addEventListener("error",y),n&&(c.referrerPolicy=n),typeof a=="string"&&(c.crossOrigin=a),()=>{c.removeEventListener("load",h),c.removeEventListener("error",y)}},[c,a,n]),d}var jC=yC,EC=SC,NC=CC;const Ss=f.forwardRef(({className:e,...n},a)=>l.jsx(jC,{ref:a,className:ce("relative flex h-10 w-10 shrink-0 overflow-hidden rounded-full",e),...n}));Ss.displayName=jC.displayName;const ws=f.forwardRef(({className:e,...n},a)=>l.jsx(EC,{ref:a,className:ce("aspect-square h-full w-full",e),...n}));ws.displayName=EC.displayName;const Cs=f.forwardRef(({className:e,...n},a)=>l.jsx(NC,{ref:a,className:ce("flex h-full w-full items-center justify-center rounded-full bg-muted",e),...n}));Cs.displayName=NC.displayName;const kC=f.createContext(null);function F_({children:e}){const[n,a]=f.useState([]),[o,i]=f.useState(0),[c,d]=f.useState(!0),m=f.useCallback(async()=>{try{const v=await Ne.getNotifications();v.data&&(a(v.data.items),i(v.data.unreadCount))}catch{}finally{d(!1)}},[]),p=f.useCallback(async()=>{var v;try{const x=await Ne.markNotificationsRead("all");a(S=>S.map(j=>({...j,read:!0}))),i(((v=x.data)==null?void 0:v.unreadCount)??0)}catch{}},[]),h=f.useCallback(async v=>{var x;try{const S=await Ne.dismissNotification(v);a(j=>j.filter(C=>C.id!==v)),i(((x=S.data)==null?void 0:x.unreadCount)??0)}catch{}},[]);f.useEffect(()=>{m();const v=setInterval(m,3e4);return()=>clearInterval(v)},[m]);const y=f.useMemo(()=>({items:n,unreadCount:o,loading:c,refresh:m,markAllRead:p,dismiss:h}),[n,o,c,m,p,h]);return l.jsx(kC.Provider,{value:y,children:e})}function Y_(){const e=f.useContext(kC);if(!e)throw new Error("useNotifications must be used within NotificationsProvider");return e}const W_=Ds("inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",{variants:{variant:{default:"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",secondary:"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",destructive:"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",outline:"text-foreground"}},defaultVariants:{variant:"default"}});function gs({className:e,variant:n,...a}){return l.jsx("div",{className:ce(W_({variant:n}),e),...a})}function q_(e){const n=K_(e),a=f.forwardRef((o,i)=>{const{children:c,...d}=o,m=f.Children.toArray(c),p=m.find(Z_);if(p){const h=p.props.children,y=m.map(v=>v===p?f.Children.count(h)>1?f.Children.only(null):f.isValidElement(h)?h.props.children:null:v);return l.jsx(n,{...d,ref:i,children:f.isValidElement(h)?f.cloneElement(h,void 0,y):null})}return l.jsx(n,{...d,ref:i,children:c})});return a.displayName=`${e}.SLine truncated
|
||||
--num-groups: ${c.groups.length};
|
||||
--indicator-opacity: ${c.searchMode?0:1};
|
||||
--num-skintones: ${Q1};`}),d(()=>{c.customEmoji&&c.database&&z()}),d(()=>{c.customEmoji&&c.customEmoji.length?c.groups!==vh&&(c.groups=vh):c.groups!==Cp&&(c.currentGroupIndex&&c.currentGroupIndex--,c.groups=Cp)}),d(()=>{async function W(){c.databaseLoaded&&(c.currentSkinTone=await c.database.getPreferredSkinTone())}W()}),d(()=>{c.skinTones=Array(Q1).fill().map((W,ie)=>Pz(c.skinToneEmoji,ie))}),d(()=>{c.skinToneButtonText=c.skinTones[c.currentSkinTone]}),d(()=>{c.skinToneButtonLabel=c.i18n.skinToneLabel.replace("{skinTone}",c.i18n.skinTones[c.currentSkinTone])}),d(()=>{async function W(){const{database:ie}=c,ge=(await Promise.all(kz.map(je=>ie.getEmojiByUnicodeOrName(je)))).filter(Boolean);c.defaultFavoriteEmojis=ge}c.databaseLoaded&&W()});function z(){const{customEmoji:W,database:ie}=c,ge=W||Rp;ie.customEmoji!==ge&&(ie.customEmoji=ge)}d(()=>{async function W(){z();const{database:ie,defaultFavoriteEmojis:ge,numColumns:je}=c,Ae=await ie.getTopFavoriteEmoji(je),tt=await he(D5([...Ae,...ge],ct=>ct.unicode||ct.name).slice(0,je));c.currentFavorites=tt}c.databaseLoaded&&c.defaultFavoriteEmojis&&W()});function B(W){Bz(W,i,()=>{{const ie=getComputedStyle(a.rootElement),ge=parseInt(ie.getPropertyValue("--num-columns"),10),je=ie.getPropertyValue("direction")==="rtl";c.numColumns=ge,c.isRtl=je}})}function H(W){sP(W,i,ie=>{for(const{target:ge,isIntersecting:je}of ie)ge.classList.toggle("onscreen",je)})}d(()=>{async function W(){const{searchText:ie,currentGroup:ge,databaseLoaded:je,customEmoji:Ae}=c;if(!je)c.currentEmojis=[],c.searchMode=!1;else if(ie.length>=wz){const tt=await O(ie);c.searchText===ie&&(S(tt),j(!0))}else{const{id:tt}=ge;if(tt!==-1||Ae&&Ae.length){const ct=await se(tt);c.currentGroup.id===tt&&(S(ct),j(!1))}}}W()});const Y=()=>{du(()=>Gz(a.tabpanelElement))};d(()=>{const{currentEmojis:W,emojiVersion:ie}=c,ge=W.filter(je=>je.unicode).filter(je=>J1(je)&&!bh.has(je.unicode));if(!ie&&ge.length)S(W),du(()=>Q(ge));else{const je=ie?W:W.filter(fe);S(je),Y()}});function Q(W){Hz(W,a.baselineEmoji,h)?Y():c.currentEmojis=[...c.currentEmojis]}function fe(W){return!W.unicode||!J1(W)||bh.get(W.unicode)}async function de(W){const ie=c.emojiVersion||await Ep();return W.filter(({version:ge})=>!ge||ge<=ie)}async function he(W){return Iz(W,c.emojiVersion||await Ep())}async function se(W){const ie=W===-1?c.customEmoji:await c.database.getEmojiByGroup(W);return he(await de(ie))}async function O(W){return he(await de(await c.database.getEmojiBySearchQuery(W)))}d(()=>{}),d(()=>{function W(){const{searchMode:ge,currentEmojis:je}=c;if(ge)return[{category:"",emojis:je}];const Ae=new Map;for(const tt of je){const ct=tt.category||"";let kt=Ae.get(ct);kt||(kt=[],Ae.set(ct,kt)),kt.push(tt)}return[...Ae.entries()].map(([tt,ct])=>({category:tt,emojis:ct})).sort((tt,ct)=>c.customCategorySorting(tt.category,ct.category))}const ie=W();C(ie)}),d(()=>{c.activeSearchItemId=c.activeSearchItem!==-1&&c.currentEmojis[c.activeSearchItem].id}),d(()=>{const{rawSearchText:W}=c;_5(()=>{c.searchText=(W||"").trim(),c.activeSearchItem=-1})});function $(W){if(!c.searchMode||!c.currentEmojis.length)return;const ie=ge=>{or(W),c.activeSearchItem=Np(ge,c.activeSearchItem,c.currentEmojis)};switch(W.key){case"ArrowDown":return ie(!1);case"ArrowUp":return ie(!0);case"Enter":if(c.activeSearchItem===-1)c.activeSearchItem=0;else return or(W),M(c.currentEmojis[c.activeSearchItem].id)}}function Z(W){const{target:ie}=W,ge=ie.closest(".nav-button");if(!ge)return;const je=parseInt(ge.dataset.groupId,10);a.searchElement.value="",c.rawSearchText="",c.searchText="",c.activeSearchItem=-1,c.currentGroupIndex=c.groups.findIndex(Ae=>Ae.id===je)}function J(W){const{target:ie,key:ge}=W,je=Ae=>{Ae&&(or(W),Ae.focus())};switch(ge){case"ArrowLeft":return je(ie.previousElementSibling);case"ArrowRight":return je(ie.nextElementSibling);case"Home":return je(ie.parentElement.firstElementChild);case"End":return je(ie.parentElement.lastElementChild)}}async function te(W){const ie=await c.database.getEmojiByUnicodeOrName(W),ge=[...c.currentEmojis,...c.currentFavorites].find(Ae=>Ae.id===W),je=ge.unicode&&N(ge,c.currentSkinTone);return await c.database.incrementFavoriteEmojiCount(W),{emoji:ie,skinTone:c.currentSkinTone,...je&&{unicode:je},...ge.name&&{name:ge.name}}}async function M(W){const ie=te(W);y("emoji-click-sync",ie),y("emoji-click",await ie)}function _(W){const{target:ie}=W;if(!ie.classList.contains("emoji"))return;or(W);const ge=ie.id.substring(4);M(ge)}function U(W){c.currentSkinTone=W,c.skinTonePickerExpanded=!1,p("skintone-button"),y("skin-tone-change",{skinTone:W}),c.database.setPreferredSkinTone(W)}function V(W){const{target:{id:ie}}=W,ge=ie&&ie.match(/^skintone-(\d)/);if(!ge)return;or(W);const je=parseInt(ge[1],10);U(je)}function F(W){c.skinTonePickerExpanded=!c.skinTonePickerExpanded,c.activeSkinTone=c.currentSkinTone,c.skinTonePickerExpanded&&(or(W),du(()=>p("skintone-list")))}d(()=>{c.skinTonePickerExpanded?a.skinToneDropdown.addEventListener("transitionend",()=>{c.ski
Line truncated
|
||||
In order to be iterable, non-array objects must have a [Symbol.iterator]() method.`)}function f0(e,n){var a=Object.keys(e);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);n&&(o=o.filter(function(i){return Object.getOwnPropertyDescriptor(e,i).enumerable})),a.push.apply(a,o)}return a}function m0(e){for(var n=1;n<arguments.length;n++){var a=arguments[n]!=null?arguments[n]:{};n%2?f0(Object(a),!0).forEach(function(o){QP(e,o,a[o])}):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(a)):f0(Object(a)).forEach(function(o){Object.defineProperty(e,o,Object.getOwnPropertyDescriptor(a,o))})}return e}function tI(e,n){if(e==null)return{};var a,o,i=nI(e,n);if(Object.getOwnPropertySymbols){var c=Object.getOwnPropertySymbols(e);for(o=0;o<c.length;o++)a=c[o],n.indexOf(a)===-1&&{}.propertyIsEnumerable.call(e,a)&&(i[a]=e[a])}return i}function nI(e,n){if(e==null)return{};var a={};for(var o in e)if({}.hasOwnProperty.call(e,o)){if(n.indexOf(o)!==-1)continue;a[o]=e[o]}return a}function rI(e,n){return ZP(e)||JP(e,n)||sI(e,n)||eI()}function aI(e,n){if(typeof e!="object"||!e)return e;var a=e[Symbol.toPrimitive];if(a!==void 0){var o=a.call(e,n);if(typeof o!="object")return o;throw new TypeError("@@toPrimitive must return a primitive value.")}return(n==="string"?String:Number)(e)}function oI(e){var n=aI(e,"string");return typeof n=="symbol"?n:n+""}function sI(e,n){if(e){if(typeof e=="string")return d0(e,n);var a={}.toString.call(e).slice(8,-1);return a==="Object"&&e.constructor&&(a=e.constructor.name),a==="Map"||a==="Set"?Array.from(e):a==="Arguments"||/^(?:Ui|I)nt(?:8|16|32)(?:Clamped)?Array$/.test(a)?d0(e,n):void 0}}function iI(e,n,a){return n in e?Object.defineProperty(e,n,{value:a,enumerable:!0,configurable:!0,writable:!0}):e[n]=a,e}function p0(e,n){var a=Object.keys(e);if(Object.getOwnPropertySymbols){var o=Object.getOwnPropertySymbols(e);n&&(o=o.filter(function(i){return Object.getOwnPropertyDescriptor(e,i).enumerable})),a.push.apply(a,o)}return a}function h0(e){for(var n=1;n<arguments.length;n++){var a=arguments[n]!=null?arguments[n]:{};n%2?p0(Object(a),!0).forEach(function(o){iI(e,o,a[o])}):Object.getOwnPropertyDescriptors?Object.defineProperties(e,Object.getOwnPropertyDescriptors(a)):p0(Object(a)).forEach(function(o){Object.defineProperty(e,o,Object.getOwnPropertyDescriptor(a,o))})}return e}function lI(){for(var e=arguments.length,n=new Array(e),a=0;a<e;a++)n[a]=arguments[a];return function(o){return n.reduceRight(function(i,c){return c(i)},o)}}function Vi(e){return function n(){for(var a=this,o=arguments.length,i=new Array(o),c=0;c<o;c++)i[c]=arguments[c];return i.length>=e.length?e.apply(this,i):function(){for(var d=arguments.length,m=new Array(d),p=0;p<d;p++)m[p]=arguments[p];return n.apply(a,[].concat(i,m))}}}function Du(e){return{}.toString.call(e).includes("Object")}function cI(e){return!Object.keys(e).length}function ll(e){return typeof e=="function"}function uI(e,n){return Object.prototype.hasOwnProperty.call(e,n)}function dI(e,n){return Du(n)||ya("changeType"),Object.keys(n).some(function(a){return!uI(e,a)})&&ya("changeField"),n}function fI(e){ll(e)||ya("selectorType")}function mI(e){ll(e)||Du(e)||ya("handlerType"),Du(e)&&Object.values(e).some(function(n){return!ll(n)})&&ya("handlersType")}function pI(e){e||ya("initialIsRequired"),Du(e)||ya("initialType"),cI(e)&&ya("initialContent")}function hI(e,n){throw new Error(e[n]||e.default)}var gI={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"},ya=Vi(hI)(gI),nu={changes:dI,selector:fI,handler:mI,initial:pI};function vI(e){var n=arguments.length>1&&arguments[1]!==void 0?arguments[1]:{};nu.initial(e),nu.handler(n);var a={current:e},o=Vi(xI)(a,n),i=Vi(yI)(a),c=Vi(nu.changes)(e),d=Vi(bI)(a);function m(){var h=arguments.length>0&&arguments[0]!==void 0?arguments[0]:function(y){return y};return nu.selector(h),h(a.current)}function p(h){lI(o,i,c,d)(h)}return[m,p]}function bI(e,n){return ll(n)?n(e.current):n}function yI(e,n){return e.current=h0(h0({},e.current),n),n}function xI(e,n,a){return ll(n)?n(e.current):Object.keys(a).forEach(function(o){var i;return(i=n[o])===null||i===void 0?void 0:i.call(n,e.current[o])}),a}var SI={create:vI},wI={paths:{vs:"https://cdn.jsdelivr.net/npm/monaco-editor@0.55.1/min/vs"}};function CI(e){return function n(){for(var a=this,o=arguments.length,i=new Array(o),c=0;c<o;c++)i[c]=arguments[c];return i.length>=e.length?e.apply(this,i):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
|
||||
`},g0=CI(kI)($5),TI={config:EI},RI=function(){for(var n=arguments.length,a=new Array(n),o=0;o<n;o++)a[o]=arguments[o];return function(i){return a.reduceRight(function(c,d){return d(c)},i)}};function B5(e,n){return Object.keys(n).forEach(function(a){n[a]instanceof Object&&e[a]&&Object.assign(n[a],B5(e[a],n[a]))}),m0(m0({},e),n)}var AI={type:"cancelation",msg:"operation is manually canceled"};function Ap(e){var n=!1,a=new Promise(function(o,i){e.then(function(c){return n?i(AI):o(c)}),e.catch(i)});return a.cancel=function(){return n=!0},a}var MI=["monaco"],_I=SI.create({config:wI,isInitialized:!1,resolve:null,reject:null,monaco:null}),U5=rI(_I,2),kl=U5[0],Md=U5[1];function OI(e){var n=TI.config(e),a=n.monaco,o=tI(n,MI);Md(function(i){return{config:B5(i.config,o),monaco:a}})}function DI(){var e=kl(function(n){var a=n.monaco,o=n.isInitialized,i=n.resolve;return{monaco:a,isInitialized:o,resolve:i}});if(!e.isInitialized){if(Md({isInitialized:!0}),e.monaco)return e.resolve(e.monaco),Ap(Mp);if(window.monaco&&window.monaco.editor)return H5(window.monaco),e.resolve(window.monaco),Ap(Mp);RI(LI,PI)(II)}return Ap(Mp)}function LI(e){return document.body.appendChild(e)}function zI(e){var n=document.createElement("script");return e&&(n.src=e),n}function PI(e){var n=kl(function(o){var i=o.config,c=o.reject;return{config:i,reject:c}}),a=zI("".concat(n.config.paths.vs,"/loader.js"));return a.onload=function(){return e()},a.onerror=n.reject,a}function II(){var e=kl(function(a){var o=a.config,i=a.resolve,c=a.reject;return{config:o,resolve:i,reject:c}}),n=window.require;n.config(e.config),n(["vs/editor/editor.main"],function(a){var o=a.m||a;H5(o),e.resolve(o)},function(a){e.reject(a)})}function H5(e){kl().monaco||Md({monaco:e})}function $I(){return kl(function(e){var n=e.monaco;return n})}var Mp=new Promise(function(e,n){return Md({resolve:e,reject:n})}),V5={config:OI,init:DI,__getMonacoInstance:$I},BI={wrapper:{display:"flex",position:"relative",textAlign:"initial"},fullWidth:{width:"100%"},hide:{display:"none"}},_p=BI,UI={container:{display:"flex",height:"100%",width:"100%",justifyContent:"center",alignItems:"center"}},HI=UI;function VI({children:e}){return ue.createElement("div",{style:HI.container},e)}var GI=VI,FI=GI;function YI({width:e,height:n,isEditorReady:a,loading:o,_ref:i,className:c,wrapperProps:d}){return ue.createElement("section",{style:{..._p.wrapper,width:e,height:n},...d},!a&&ue.createElement(FI,null,o),ue.createElement("div",{ref:i,style:{..._p.fullWidth,...!a&&_p.hide},className:c}))}var WI=YI,G5=f.memo(WI);function qI(e){f.useEffect(e,[])}var F5=qI;function KI(e,n,a=!0){let o=f.useRef(!0);f.useEffect(o.current||!a?()=>{o.current=!1}:e,n)}var xn=KI;function qi(){}function ms(e,n,a,o){return XI(e,o)||ZI(e,n,a,o)}function XI(e,n){return e.editor.getModel(Y5(e,n))}function ZI(e,n,a,o){return e.editor.createModel(n,a,o?Y5(e,o):void 0)}function Y5(e,n){return e.Uri.parse(n)}function QI({original:e,modified:n,language:a,originalLanguage:o,modifiedLanguage:i,originalModelPath:c,modifiedModelPath:d,keepCurrentOriginalModel:m=!1,keepCurrentModifiedModel:p=!1,theme:h="light",loading:y="Loading...",options:v={},height:x="100%",width:S="100%",className:j,wrapperProps:C={},beforeMount:N=qi,onMount:E=qi}){let[k,T]=f.useState(!1),[R,D]=f.useState(!0),P=f.useRef(null),z=f.useRef(null),B=f.useRef(null),H=f.useRef(E),Y=f.useRef(N),Q=f.useRef(!1);F5(()=>{let se=V5.init();return se.then(O=>(z.current=O)&&D(!1)).catch(O=>(O==null?void 0:O.type)!=="cancelation"&&console.error("Monaco initialization: error:",O)),()=>P.current?he():se.cancel()}),xn(()=>{if(P.current&&z.current){let se=P.current.getOriginalEditor(),O=ms(z.current,e||"",o||a||"text",c||"");O!==se.getModel()&&se.setModel(O)}},[c],k),xn(()=>{if(P.current&&z.current){let se=P.current.getModifiedEditor(),O=ms(z.current,n||"",i||a||"text",d||"");O!==se.getModel()&&se.setModel(O)}},[d],k),xn(()=>{let se=P.current.getModifiedEditor();se.getOption(z.current.editor.EditorOption.readOnly)?se.setValue(n||""):n!==se.getValue()&&(se.executeEdits("",[{range:se.getModel().getFullModelRange(),text:n||"",forceMoveMarkers:!0}]),se.pushUndoStop())},[n],k),xn(()=>{var se,O;(O=(se=P.current)==null?void 0:se.getModel())==null||O.original.setValue(e||"")},[e],k),xn(()=>{let{original:se,modified:O}=P.current.getModel();z.current.editor.setModelLanguage(se,o||a||"text"),z.current.editor.setModelLanguage(O,i||a||"text")},[a,o,i],k),xn(()=>{var se;(se=z.current)==null||se.editor.setTheme(h)},[h],k),xn(()=>{var se;(se=P.current)==null||se.updateOptions(v)},[v],k);let fe=f.useCallback(()=>{var $;if(!z.current)return;Y.current(z.current);let se=ms(z.current,e||"",o||a||"text",c||""),O=ms(z.current,n||"",i||a||"text",d||"");($=P.current)==null||$.setModel({original:se,modified:O})},[a,n,i,e,o,c,d]),de=f.useCallback(()=>{var se;!Q.current&&B.current&&(P.current=z.current.editor.createDiffEditor(B.current,{automaticLayout:!0,...v}),fe(),(se=z.current)==null||se.editor.setTheme(h),T(!0),Q.current=!0
Line truncated
|
||||
987654321098765432`,className:"min-h-[120px] font-mono text-sm"})]}),l.jsx(Us,{result:a.owners}),l.jsx("div",{className:"mt-auto",children:l.jsx(ve,{type:"button",variant:"secondary",disabled:o||!e.ownerUserIds.trim(),onClick:i,children:o?"Checking with Discord…":"Verify owner IDs"})})]})}function k$({onContinue:e}){return l.jsx("div",{className:"mx-auto flex w-full max-w-lg flex-1 flex-col justify-center py-8",children:l.jsxs("div",{className:"rounded-2xl border border-border bg-card px-6 py-10 text-center md:px-10",children:[l.jsx("div",{className:"mx-auto mb-6 flex size-16 items-center justify-center rounded-2xl bg-primary/10 text-primary",children:l.jsx(ye,{icon:Bu,size:40})}),l.jsx("h2",{className:"text-2xl font-semibold tracking-tight",children:"Setup complete"}),l.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"," ",l.jsx("code",{className:"rounded-md bg-muted px-1.5 py-0.5 font-mono text-xs",children:"npm start"}),")."]}),l.jsx(Ye,{className:"mt-6 text-left",children:l.jsx(We,{children:"After restarting, sign in with Discord to open the dashboard."})}),l.jsx(ve,{type:"button",size:"lg",className:"mt-8 min-w-[220px]",onClick:e,children:"Continue to sign in"})]})})}function T$({currentIndex:e,validating:n,saving:a,onBack:o,onNext:i,onFinish:c}){const d=e===bs.length-1;return l.jsxs("div",{className:"flex flex-wrap items-center justify-between gap-3",children:[l.jsxs(ve,{type:"button",variant:"ghost",disabled:e===0||a,onClick:o,children:[l.jsx(ye,{icon:G0,size:18,className:"mr-1"}),"Back"]}),l.jsx("div",{className:"flex flex-1 justify-end gap-2",children:d?l.jsx(ve,{type:"button",size:"lg",disabled:a||n,onClick:c,className:"min-w-[200px]",children:a?"Saving…":"Finish setup"}):l.jsxs(ve,{type:"button",size:"lg",disabled:n||a,onClick:i,children:["Continue",l.jsx(ye,{icon:Oh,size:18,className:"ml-1"})]})})]})}function R$({values:e,setValues:n,validation:a,validating:o,onValidate:i}){return l.jsxs("div",{className:"flex flex-1 flex-col gap-5",children:[l.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"," ",l.jsx("a",{href:"https://emeraldsrv.dev",target:"_blank",rel:"noreferrer",className:"font-medium text-primary hover:underline",children:"Emerald Services Marketplace"}),"."]}),l.jsxs("div",{className:"space-y-2",children:[l.jsx(De,{htmlFor:"emeraldApiKey",children:"Emerald API key (Optional)"}),l.jsx(Ue,{id:"emeraldApiKey",type:"password",autoComplete:"off",value:e.emeraldApiKey,onChange:c=>n({...e,emeraldApiKey:c.target.value}),placeholder:"em_...",className:"h-11 font-mono text-sm"})]}),l.jsx(Us,{result:a.emeraldApi}),l.jsx("div",{className:"mt-auto",children:l.jsx(ve,{type:"button",variant:"secondary",disabled:o,onClick:i,children:o?"Checking…":e.emeraldApiKey?"Verify key":"Skip step"})})]})}function A$(){const e=crypto.randomUUID().replace(/-/g,""),n=crypto.randomUUID().replace(/-/g,"");return e+n}function M$(e){return e.split(/[\s,]+/).map(n=>n.trim()).filter(Boolean)}function v0(e){return e==="oauth"?"oauth":e==="owners"?"owners":e==="emeraldApi"?"emeraldApi":e}function _$(){const e=mo(),{branding:n}=Fu(),{loading:a,error:o,refresh:i,status:c}=GC(),[d,m]=f.useState(0),[p,h]=f.useState({botToken:"",secretKey:"",clientId:"",clientSecret:"",redirectUri:"",ownerUserIds:"",emeraldApiKey:""}),[y,v]=f.useState({}),[x,S]=f.useState(!1),[j,C]=f.useState(!1),[N,E]=f.useState(""),[k,T]=f.useState(!1),R=bs[d];f.useEffect(()=>{c!=null&&c.suggestedRedirectUri&&!p.redirectUri&&h(Q=>({...Q,redirectUri:c.suggestedRedirectUri}))},[c==null?void 0:c.suggestedRedirectUri,p.redirectUri]);const D=f.useMemo(()=>({botToken:p.botToken,secretKey:p.secretKey,clientId:p.clientId,clientSecret:p.clientSecret,redirectUri:p.redirectUri,ownerUserIds:M$(p.ownerUserIds),emeraldApiKey:p.emeraldApiKey}),[p]);async function P(){S(!0),E("");try{const Q=await Ne.validateSetup(D);return v(fe=>{var de;return{...fe,...(de=Q.data)==null?void 0:de.results}}),Q.data}catch(Q){return E(Q instanceof Error?Q.message:"Validation failed"),null}finally{S(!1)}}async function z(){var de,he;const Q=v0(R.id),fe=await P();return!!((he=(de=fe==null?void 0:fe.results)==null?void 0:de[Q])!=null&&he.ok)}async function B(){const Q=v0(R.id),fe=y[Q];!(fe!=null&&fe.ok)&&!await z()||m(de=>Math.min(de+1,bs.length-1))}async function H(){C(!0),E("");try{const Q=await P();if(!(Q!=null&&Q.ok)){E("Fix the steps marked with errors before finishing.");return}await Ne.completeSetup(D),T(!0)}catch(Q){E(Q instanceof Error?Q.message:"Could not save setup")}finally{C(!1)}}function Y(){const Q={values:p,setValues:h,validation:y,validating:x};switch(R.id){case"botToken":return l.jsx(w$,{...Q,onValidate:()=>void z()}
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-BOdhXLlb.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/assets/index-DoxvJPsp.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