diff --git a/.github/workflows/pipeline.yml b/.github/workflows/pipeline.yml new file mode 100644 index 0000000..de695ae --- /dev/null +++ b/.github/workflows/pipeline.yml @@ -0,0 +1,26 @@ +name: Build Sokora + +on: + push: + branches: + - dev + +jobs: + build: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - name: Checkout repository + uses: actions/checkout@v3 + + - name: Set up Node.js + uses: actions/setup-node@v3 + with: + node-version: '20.x' + + - name: Install packages and build + run: npm install && npx tsc -b ./ + diff --git a/.gitignore b/.gitignore index 3ec544c..00ad2e5 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ node_modules/ -.env \ No newline at end of file +.env +.DS_Store +data.db \ No newline at end of file diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..9d5a120 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,7 @@ +{ + "tabWidth": 2, + "useTabs": false, + "arrowParens": "avoid", + "trailingComma": "none", + "printWidth": 100 +} diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4d62514..a6f3644 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,12 @@ -# Nebula Contributing Guide +# Sokora Contributing Guide ## Prerequisites - Basic knowledge of [TypeScript](https://typescriptlang.org/) and [discord.js](https://discord.js.org/). -- [Bun](https://bun.sh) and [MySQL](https://mysql.com/) installed. +- [Bun](https://bun.sh) installed. -## Get started with developing +## Get started with contributing ### Getting the code -- Make a fork of this repository. +- Make a fork of this repository. - Clone your fork. ### Creating your bot @@ -14,18 +14,11 @@ - Invite your bot to your server. - Reset and then copy your bot's token. -### Setting up your database -We use MySQL for the database. You need to set one up to be able to run the bot. - -- Create a database file called `json.db` -- It is recommended to create a specific user for Nebula only. See [the docs](https://dev.mysql.com/doc/refman/8.0/en/creating-accounts.html) for more information - ### Setting up .env -- Copy the `example.env` file and replace the content with your own credentials inside a file called `.env`. +- Run `bun run setup` and our cli tool will install dependencies and write .env for you ### Running -- Run `bun i` to install all the modules needed by the bot. -- Run `bun start` +- Run `bun start`. Be sure to open a pull request when you're ready to push your changes. Be descriptive of the changes you've made. diff --git a/README.md b/README.md index 3596e2f..6685757 100644 --- a/README.md +++ b/README.md @@ -1,19 +1,17 @@

- +

- +

# About -Nebula is a multiplatform, multipurpose bot with the ability to add extensions to have additional features. +Sokora is a multiplatform, multipurpose bot with the ability to add extensions to have additional features. -**Please note that Nebula is currently in an alpha preview state and only usable within Discord with limited functionality.** +**Please note that Sokora is currently unstable and is only usable within Discord.** # Contributing While we're developing the multiplatform version of the bot, you can still [help us](CONTRIBUTING.MD) if you find any bugs. - -Only bug fixes are accepted, **no new features!** diff --git a/bun.lockb b/bun.lockb index 8c93d62..e1a9baa 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/cli/setup.ts b/cli/setup.ts new file mode 100644 index 0000000..52c22d3 --- /dev/null +++ b/cli/setup.ts @@ -0,0 +1,50 @@ +import * as fs from "fs"; +import { createInterface } from "readline"; + +const readHiddenInput = async (prompt: string): Promise => { + console.log(prompt); + + return new Promise(resolve => { + const rl = createInterface({ + input: process.stdin, + output: process.stdout + }); + + const stdin = process.stdin; + if (stdin.isTTY) stdin.setRawMode(true); + + stdin.on("data", data => { + if (data.toString() === "\n" || data.toString() === "\r") { + if (stdin.isTTY) stdin.setRawMode(false); + process.stdout.moveCursor(0, -1); + process.stdout.clearLine(1); + rl.close(); + } + }); + + rl.question("", input => { + resolve(input); + }); + }); +}; + +const replaceTokenInEnv = (token: string) => { + try { + const envContent = fs.readFileSync(".env", "utf-8"); + const updatedContent = envContent.replace("YOURTOKEN", token); + fs.writeFileSync(".env", updatedContent, "utf-8"); + console.log("You're good to go, happy coding!"); + } catch (error) { + console.error("Error updating .env file:", error); + } +}; + +const main = async () => { + fs.copyFileSync("example.env", ".env"); + const token = await readHiddenInput( + "Paste your token below: (you can get one from https://discord.com/developers/applications)" + ); + replaceTokenInEnv(token); +}; + +main().catch(console.error); diff --git a/example.env b/example.env index 7f366d8..8973729 100644 --- a/example.env +++ b/example.env @@ -1,7 +1 @@ -TOKEN="YOURTOKEN" # The bot token found in the Discord Developer portal - -MYSQL_HOST="localhost" # Name of the host -MYSQL_PORT="1234" -MYSQL_USERNAME="username" # Username USED BY YOUR DATABASE -MYSQL_DATABASE="nebula_stable" -MYSQL_PASSWORD="Password123" +TOKEN="YOURTOKEN" diff --git a/package.json b/package.json index d4bc0a5..91dd4ed 100644 --- a/package.json +++ b/package.json @@ -1,27 +1,26 @@ { - "name": "nebula", - "description": "Welcome to Nebula, the multipurpose, multiplatform bot.", + "name": "sokora", + "description": "Welcome to Sokora, a multipurpose Discord bot that lets you manage your servers easily.", "contributors": [ - "The Nebula team", + "The Sokora team", "The GitHub contributors" ], "version": "0.1.0", "main": "./src/index.ts", "type": "module", "scripts": { + "setup": "bun i && bun run cli/setup.ts", "start": "bun ./src/index.ts" }, "dependencies": { - "discord.js": "^14.14.1", + "discord.js": "^14.16.3", "ms": "^2.1.3", - "mysql2": "^3.6.3", "node-vibrant": "^3.2.1-alpha.1", - "quick.db": "^9.1.7", - "sharp": "v0.33.0-alpha.11" + "sharp": "^0.33.5" }, "devDependencies": { - "bun-types": "0.8.1", - "typescript": "^5.2.2" - }, - "trustedDependencies": ["sharp"] + "@types/ms": "^0.7.34", + "bun-types": "^1.1.33", + "typescript": "^5.6.3" + } } diff --git a/src/bot.ts b/src/bot.ts index 7d08495..bd8e19b 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,10 +1,11 @@ -import { Client, ActivityType } from "discord.js"; -import Commands from "./handlers/commands.js"; -import Events from "./handlers/events.js"; +import { ActivityType, Client } from "discord.js"; +import { Commands } from "./handlers/commands"; +import { Events } from "./handlers/events"; +import { rescheduleUnbans } from "./utils/unbanScheduler"; const client = new Client({ presence: { - activities: [{ name: "with /settings!", type: ActivityType.Playing }] + activities: [{ name: "your feedback!", type: ActivityType.Listening }] }, intents: [ "Guilds", @@ -12,15 +13,16 @@ const client = new Client({ "GuildMessages", "GuildEmojisAndStickers", "GuildPresences", + "GuildBans", "MessageContent" ] }); client.on("ready", async () => { - new Events(client); - console.log("Starting all commands."); + await new Events(client).loadEvents(); await new Commands(client).registerCommands(); console.log("ちーっす!"); + rescheduleUnbans(client); }); client.login(process.env.TOKEN); diff --git a/src/commands/About.ts b/src/commands/About.ts new file mode 100644 index 0000000..81adb7a --- /dev/null +++ b/src/commands/About.ts @@ -0,0 +1,78 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonStyle, + EmbedBuilder, + SlashCommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../utils/colorGen"; +import { imageColor } from "../utils/imageColor"; +import { randomise } from "../utils/randomise"; + +export default class About { + data: SlashCommandBuilder; + constructor() { + this.data = new SlashCommandBuilder() + .setName("about") + .setDescription("Shows information about Sokora."); + } + + async run(interaction: ChatInputCommandInteraction) { + const client = interaction.client; + const user = client.user; + const guilds = client.guilds.cache; + const members = guilds.map(guild => guild.memberCount).reduce((a, b) => a + b); + const shards = client.shard?.count; + const avatar = user.displayAvatarURL(); + let emojis = ["💖", "💝", "💓", "💗", "💘", "💟", "💕", "💞"]; + if (Math.round(Math.random() * 100) <= 5) emojis = ["⌨️", "💻", "🖥️"]; + + const embed = new EmbedBuilder() + .setAuthor({ name: "• About Sokora", iconURL: avatar }) + .setDescription( + "Sokora is a multipurpose Discord bot that lets you manage your servers easily." + ) + .setFields( + { + name: "📃 • General", + value: [ + "Version **0.1**, *Kaishi*", + `**${members}** members • **${guilds.size}** guild${guilds.size == 1 ? "" : "s"} ${ + !shards ? "" : `• **${shards}** shard${shards == 1 ? "" : "s"}` + }` + ].join("\n") + }, + { + name: "🌌 • Entities involved", + value: [ + "**Founder**: Goos", + "**Translator Lead**: ThatBOI", + "**Developers**: Dimkauzh, Froxcey, Golem64, Koslz, MQuery, Nikkerudon, Spectrum, ThatBOI", + "**Designers**: ArtyH, ZakaHaceCosas, Pjanda", + "**Translators**: Dimkauzh, flojo, Golem64, GraczNet, Nikkerudon, ZakaHaceCosas, SaFire, TrulyBlue", + "**Testers**: Blaze, fishy, Trynera", + "And **YOU**, for using Sokora." + ].join("\n") + }, + { + name: "🔗 • Links", + value: + "[GitHub](https://www.github.com/NebulaTheBot) • [YouTube](https://www.youtube.com/@NebulaTheBot) • [Instagram](https://instagram.com/NebulaTheBot) • [Mastodon](https://mastodon.online/@NebulaTheBot@mastodon.social) • [Guilded](https://guilded.gg/Nebula) • [Revolt](https://rvlt.gg/28TS9aXy)" + } + ) + .setFooter({ text: `Made with ${randomise(emojis)} by the Sokora team` }) + .setThumbnail(avatar) + .setColor(user.hexAccentColor ?? (await imageColor(undefined, avatar)) ?? genColor(270)); + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel("• Donate") + .setURL("https://paypal.me/SokoraTheBot") + .setEmoji("⭐") + .setStyle(ButtonStyle.Link) + ); + + await interaction.reply({ embeds: [embed], components: [row] }); + } +} diff --git a/src/commands/Leaderboard.ts b/src/commands/Leaderboard.ts new file mode 100644 index 0000000..fa778a5 --- /dev/null +++ b/src/commands/Leaderboard.ts @@ -0,0 +1,111 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + EmbedBuilder, + SlashCommandBuilder, + type ChatInputCommandInteraction, + type SlashCommandOptionsOnlyBuilder +} from "discord.js"; +import { genColor } from "../utils/colorGen"; +import { getGuildLeaderboard } from "../utils/database/leveling"; +import { errorEmbed } from "../utils/embeds/errorEmbed"; + +export default class Leaderboard { + data: SlashCommandOptionsOnlyBuilder; + constructor() { + this.data = new SlashCommandBuilder() + .setName("leaderboard") + .setDescription("Displays the guild leaderboard.") + .addNumberOption(option => option.setName("page").setDescription("Page number to display.")); + } + + async run(interaction: ChatInputCommandInteraction) { + const guildID = interaction.guild?.id; + if (!guildID) return errorEmbed(interaction, "This command can only be used in a server."); + + const leaderboardData = getGuildLeaderboard(guildID); + if (!leaderboardData.length) + return errorEmbed( + interaction, + "No data found.", + "There is no leveling data for this server yet." + ); + + leaderboardData.sort((a, b) => { + if (b.level != a.level) return b.level - a.level; + else return b.xp - a.xp; + }); + + const totalPages = Math.ceil(leaderboardData.length / 5); + let page = interaction.options.getNumber("page") || 1; + page = Math.max(1, Math.min(page, totalPages)); + const generateEmbed = async () => { + const start = (page - 1) * 5; + const end = start + 5; + const pageData = leaderboardData.slice(start, end); + + const embed = new EmbedBuilder() + .setAuthor({ name: "Leaderboard" }) + .setColor(genColor(200)) + .setFooter({ text: `Page ${page}/${totalPages}` }); + + for (let i = 0; i < pageData.length; i++) { + const userData = pageData[i]; + const user = await interaction.client.users.fetch(userData.user); + embed.addFields({ + name: `#${start + i + 1} • ${user.tag}`, + value: `Level **${Math.floor(userData.level)}** • **${Math.floor(userData.xp)}** XP` + }); + } + + return embed; + }; + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("left") + .setEmoji("1298708251256291379") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("right") + .setEmoji("1298708281493160029") + .setStyle(ButtonStyle.Primary) + ); + + const reply = await interaction.reply({ + embeds: [await generateEmbed()], + components: totalPages > 1 ? [row] : [], + fetchReply: true + }); + + if (totalPages > 1) { + const collector = reply.createMessageComponentCollector({ + time: 30000 + }); + + collector.on("collect", async (i: ButtonInteraction) => { + if (i.message.id != (await reply.fetch()).id) + return await errorEmbed( + i, + "For some reason, this click would've caused the bot to error. Thankfully, this message right here prevents that." + ); + + if (i.user.id != interaction.user.id) + return errorEmbed(i, "You are not the person who executed this command."); + + collector.resetTimer({ time: 30000 }); + if (i.customId == "left") page = page > 1 ? page - 1 : totalPages; + else page = page < totalPages ? page + 1 : 1; + + await i.update({ + embeds: [await generateEmbed()], + components: [row] + }); + }); + + collector.on("end", async () => await interaction.editReply({ components: [] })); + } + } +} diff --git a/src/commands/Server.ts b/src/commands/Server.ts new file mode 100644 index 0000000..9c5ac8f --- /dev/null +++ b/src/commands/Server.ts @@ -0,0 +1,16 @@ +import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { serverEmbed } from "../utils/embeds/serverEmbed"; + +export default class Server { + data: SlashCommandBuilder; + constructor() { + this.data = new SlashCommandBuilder() + .setName("server") + .setDescription("Shows this server's info."); + } + + async run(interaction: ChatInputCommandInteraction) { + const embed = await serverEmbed({ guild: interaction.guild!, roles: true }); + await interaction.reply({ embeds: [embed] }); + } +} diff --git a/src/commands/Serverboard.ts b/src/commands/Serverboard.ts new file mode 100644 index 0000000..cef27aa --- /dev/null +++ b/src/commands/Serverboard.ts @@ -0,0 +1,90 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + SlashCommandBuilder, + type ChatInputCommandInteraction, + type SlashCommandOptionsOnlyBuilder +} from "discord.js"; +import { listPublicServers } from "../utils/database/settings"; +import { errorEmbed } from "../utils/embeds/errorEmbed"; +import { serverEmbed } from "../utils/embeds/serverEmbed"; + +export default class Serverboard { + data: SlashCommandOptionsOnlyBuilder; + constructor() { + this.data = new SlashCommandBuilder() + .setName("serverboard") + .setDescription("Shows the servers that have Sokora.") + .addNumberOption(number => + number.setName("page").setDescription("The page you want to see.") + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const guildList = ( + await Promise.all(listPublicServers().map(id => interaction.client.guilds.fetch(id))) + ).sort((a, b) => b.memberCount - a.memberCount); + + const pages = guildList.length; + if (!pages) + return await errorEmbed( + interaction, + "No public server found.", + "By some magical miracle, all the servers using Sokora turned off their visibility. Use /settings serverboard `shown: True` to make your server publicly visible." + ); + + const argPage = interaction.options.getNumber("page") as number; + let page = (argPage - 1 <= 0 ? 0 : argPage - 1 > pages ? pages - 1 : argPage - 1) || 0; + + async function getEmbed() { + return await serverEmbed({ guild: guildList[page], page: page + 1, pages }); + } + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("left") + .setEmoji("1298708251256291379") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("right") + .setEmoji("1298708281493160029") + .setStyle(ButtonStyle.Primary) + ); + + const reply = await interaction.reply({ + embeds: [await getEmbed()], + components: pages != 1 ? [row] : [] + }); + if (pages == 1) return; + + const collector = reply.createMessageComponentCollector({ time: 30000 }); + collector.on("collect", async (i: ButtonInteraction) => { + if (i.message.id != (await reply.fetch()).id) + return await errorEmbed( + i, + "For some reason, this click would've caused the bot to error. Thankfully, this message right here prevents that." + ); + + if (i.user.id != interaction.user.id) + return await errorEmbed(i, "You aren't the person who executed this command."); + + collector.resetTimer({ time: 30000 }); + switch (i.customId) { + case "left": + page--; + if (page < 0) page = pages - 1; + break; + case "right": + page++; + if (page >= pages) page = 0; + break; + } + + await i.update({ embeds: [await getEmbed()], components: [row] }); + }); + + collector.on("end", async () => await interaction.editReply({ components: [] })); + } +} diff --git a/src/commands/Settings.ts b/src/commands/Settings.ts new file mode 100644 index 0000000..472faf7 --- /dev/null +++ b/src/commands/Settings.ts @@ -0,0 +1,126 @@ +import { + AutocompleteInteraction, + EmbedBuilder, + InteractionType, + PermissionsBitField, + SlashCommandBuilder, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../utils/colorGen"; +import { + getSetting, + setSetting, + settingsDefinition, + settingsKeys +} from "../utils/database/settings"; +import { errorEmbed } from "../utils/embeds/errorEmbed"; + +export default class Settings { + data: SlashCommandBuilder; + constructor() { + this.data = new SlashCommandBuilder() + .setName("settings") + .setDescription("Configure Sokora to your liking.") + .setDefaultMemberPermissions(PermissionsBitField.Flags.Administrator); + + settingsKeys.forEach(key => { + const subcommand = new SlashCommandSubcommandBuilder() + .setName(key) + .setDescription(settingsDefinition[key].description); + + Object.keys(settingsDefinition[key].settings).forEach(sub => { + switch (settingsDefinition[key].settings[sub]["type"] as string) { + case "BOOL": + subcommand.addBooleanOption(option => + option + .setName(sub) + .setDescription(settingsDefinition[key].settings[sub]["desc"]) + .setRequired(false) + ); + break; + case "INTEGER": + subcommand.addIntegerOption(option => + option + .setName(sub) + .setDescription(settingsDefinition[key].settings[sub]["desc"]) + .setRequired(false) + ); + break; + case "USER": + subcommand.addUserOption(option => + option + .setName(sub) + .setDescription(settingsDefinition[key].settings[sub]["desc"]) + .setRequired(false) + ); + break; + default: // Also includes "TEXT" + subcommand.addStringOption(option => + option + .setName(sub) + .setDescription(settingsDefinition[key].settings[sub]["desc"]) + .setRequired(false) + ); + break; + } + }); + this.data.addSubcommand(subcommand); + }); + } + + async run(interaction: ChatInputCommandInteraction) { + const guild = interaction.guild!; + if ( + !guild.members.cache + ?.get(interaction.user.id) + ?.permissions.has(PermissionsBitField.Flags.Administrator) + ) + return await errorEmbed( + interaction, + "You can't execute this command.", + "You need the **Administrator** permission." + ); + + const key = interaction.options.getSubcommand() as keyof typeof settingsDefinition; + const values = interaction.options.data[0].options!; + if (!values.length) { + const embed = new EmbedBuilder().setTitle(`Settings for ${key}`).setColor(genColor(100)); + const description: string[] = []; + + Object.keys(settingsDefinition[key]).forEach(name => { + description.push(`${name}: ${getSetting(guild.id, key, name)?.toString() || "Not set"}`); + embed.setDescription(description.join("\n")); + }); + + return await interaction.reply({ embeds: [embed] }); + } + + const embed = new EmbedBuilder().setTitle(`Parameters changed`).setColor(genColor(100)); + values.forEach(option => { + setSetting(guild.id, key, option.name, option.value as string); + embed.addFields({ + name: option.name, + value: option.value?.toString() || "Not set" + }); + }); + + await interaction.reply({ embeds: [embed] }); + } + + async autocomplete(interaction: AutocompleteInteraction) { + if (interaction.type != InteractionType.ApplicationCommandAutocomplete) return; + switch (Object.keys(settingsDefinition[interaction.options.getSubcommand()])[0]) { + case "BOOL": + await interaction.respond( + ["true", "false"].map(choice => ({ + name: choice, + value: choice + })) + ); + break; + default: + await interaction.respond([]); + } + } +} diff --git a/src/commands/User.ts b/src/commands/User.ts new file mode 100644 index 0000000..05c0c47 --- /dev/null +++ b/src/commands/User.ts @@ -0,0 +1,156 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + EmbedBuilder, + SlashCommandBuilder, + type ChatInputCommandInteraction, + type SlashCommandOptionsOnlyBuilder +} from "discord.js"; +import { genColor } from "../utils/colorGen"; +import { getLevel } from "../utils/database/leveling"; +import { getSetting } from "../utils/database/settings"; +import { errorEmbed } from "../utils/embeds/errorEmbed"; +import { imageColor } from "../utils/imageColor"; + +export default class User { + data: SlashCommandOptionsOnlyBuilder; + constructor() { + this.data = new SlashCommandBuilder() + .setName("user") + .setDescription("Shows your (or another user's) info.") + .addUserOption(user => user.setName("user").setDescription("Select the user.")); + } + + async run(interaction: ChatInputCommandInteraction) { + const guild = interaction.guild!; + const user = interaction.options.getUser("user") ?? interaction.user; + const target = guild.members.cache.get(user.id); + const avatar = target?.displayAvatarURL() ?? user.displayAvatarURL(); + const embedColor = + (await target?.user.fetch())?.hexAccentColor ?? + (await imageColor(undefined, avatar)) ?? + genColor(200); + + let embed = new EmbedBuilder() + .setAuthor({ + name: `${avatar ? "• " : ""}${target?.nickname ?? user.displayName}`, + iconURL: avatar + }) + .setFields({ + name: `<:discord:1266797021126459423> • Discord info`, + value: [ + `Username is **${user.username}**`, + `Display name is ${ + user.displayName == user.username ? "*not there*" : `**${user.displayName}**` + }`, + `Created on ****` + ].join("\n") + }) + .setFooter({ text: `User ID: ${user.id}` }) + .setThumbnail(avatar) + .setColor(embedColor); + + await interaction.reply({ embeds: [embed] }); + + if (!target) return; + let serverInfo = [`Joined on ****`]; + const guildRoles = guild.roles.cache.filter(role => target.roles.cache.has(role.id))!; + const memberRoles = [...guildRoles].sort( + (role1, role2) => role2[1].position - role1[1].position + ); + memberRoles.pop(); + const rolesLength = memberRoles.length; + + if (target.premiumSinceTimestamp) + serverInfo.push(`Boosting since **${target.premiumSinceTimestamp}**`); + + if (memberRoles.length) + serverInfo.push( + `**${guildRoles.filter(role => target.roles.cache.has(role.id)).size! - 1}** ${ + memberRoles.length == 1 ? "role" : "roles" + } • ${memberRoles + .slice(0, 5) + .map(role => `<@&${role[1].id}>`) + .join(", ")}${rolesLength > 3 ? ` and **${rolesLength - 3}** more` : ""}` + ); + + embed.addFields({ + name: "📒 • Server info", + value: serverInfo.join("\n") + }); + + const enabled = getSetting(`${guild.id}`, "leveling", "enabled"); + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("general") + .setLabel("• General") + .setEmoji("📃") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("level") + .setLabel("• Level") + .setEmoji("⚡") + .setStyle(ButtonStyle.Primary) + ); + row.components[0].setDisabled(true); + const reply = await interaction.editReply({ + embeds: [embed], + components: !user.bot ? (enabled ? [row] : []) : [] + }); + + if (!enabled && user.bot) return; + const difficulty = getSetting(guild.id, "leveling", "difficulty") as number; + const [level, xp] = getLevel(guild.id, target.id)!; + const nextLevelXp = Math.floor( + 100 * difficulty * (level + 1) ** 2 - 85 * difficulty * level ** 2 + )?.toLocaleString("en-US"); + + const collector = reply.createMessageComponentCollector({ time: 30000 }); + collector.on("collect", async (i: ButtonInteraction) => { + if (i.message.id != (await reply.fetch()).id) + return await errorEmbed( + i, + "For some reason, this click would've caused the bot to error. Thankfully, this message right here prevents that." + ); + + if (i.user.id != interaction.user.id) + return await errorEmbed(i, "You aren't the person who executed this command."); + + collector.resetTimer({ time: 30000 }); + i.customId == "general" + ? row.components[0].setDisabled(true) + : row.components[1].setDisabled(true); + + const levelEmbed = new EmbedBuilder() + .setAuthor({ + name: `• ${target.nickname ?? user.displayName}`, + iconURL: target.displayAvatarURL() + }) + .setFields({ + name: `⚡ • Level ${level}`, + value: [ + `**${xp.toLocaleString("en-US")}/${nextLevelXp}** XP`, + `The next level is **${level + 1}**` + ].join("\n") + }) + .setFooter({ text: `User ID: ${target.id}` }) + .setThumbnail(target.displayAvatarURL()) + .setColor(embedColor); + + switch (i.customId) { + case "general": + row.components[1].setDisabled(false); + await i.update({ embeds: [embed], components: [row] }); + break; + case "level": + row.components[0].setDisabled(false); + await i.update({ embeds: [levelEmbed], components: [row] }); + break; + } + }); + + collector.on("end", async () => await interaction.editReply({ components: [] })); + } +} diff --git a/src/commands/info/news.ts b/src/commands/info/news.ts deleted file mode 100644 index 603a771..0000000 --- a/src/commands/info/news.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, ActionRowBuilder, - ButtonBuilder, ButtonStyle, type ChatInputCommandInteraction -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import { getNewsTable } from "../../utils/database.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; - -export default class News { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("news") - .setDescription("The news of the current server you're in.") - .addNumberOption(option => option - .setName("page") - .setDescription("The page of the news you want to see") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const newsletterTable = await getNewsTable(db); - - const guild = interaction.guild; - let page = interaction.options.getNumber("page") ?? 1; - - const news = await newsletterTable?.get(`${guild.id}.news`).then( - (news: any) => news as any[] ?? [] - ).catch(() => []); - if (!news) return await interaction.followUp({ - embeds: [errorEmbed("No news found.\nAdmins can add news with the **/settings news add** command.")] - }); - - const newsSorted = (Object.values(news).map((newsItem: any, i) => { - return { - id: Object.keys(news)[i], - ...newsItem - } - }) as any[])?.sort((a, b) => b.createdAt - a.createdAt); - if (!newsSorted) return await interaction.followUp({ - embeds: [errorEmbed("No news found.\nAdmins can add news with the **/settings news add** command.")] - }); - - if (newsSorted.length == 0) { - return await interaction.followUp({ - embeds: [errorEmbed("No news found.\nAdmins can add news with the **/settings news add** command.")] - }); - } - - if (page > newsSorted.length) page = newsSorted.length; - if (page < 1) page = 1; - - let currentNews = newsSorted[page - 1]; - let newsEmbed = new EmbedBuilder() - .setAuthor({ name: currentNews.author, iconURL: currentNews.authorPfp ?? null }) - .setTitle(currentNews.title) - .setDescription(currentNews.body) - .setImage(currentNews.imageURL || null) - .setTimestamp(parseInt(currentNews.updatedAt)) - .setFooter({ text: `Page ${page} of ${newsSorted.length} • ID: ${currentNews.id}` }) - .setColor(genColor(200)); - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("left") - .setEmoji("1137330341472915526") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId("right") - .setEmoji("1137330125004869702") - .setStyle(ButtonStyle.Primary) - ); - - await interaction.followUp({ embeds: [newsEmbed], components: [row] }); - - const buttonCollector = interaction.channel.createMessageComponentCollector({ - filter: i => i.user.id === interaction.user.id, - time: 60000 - }); - - buttonCollector.on("collect", async i => { - if (!i.isButton()) return; - const id = i.customId; - - if (id == "left") { - page--; - if (page < 1) page = newsSorted.length; - } else if (id == "right") { - page++; - if (page > newsSorted.length) page = 1; - } - - currentNews = newsSorted[page - 1]; - newsEmbed = new EmbedBuilder() - .setAuthor({ name: currentNews.author, iconURL: currentNews.authorPfp ?? null }) - .setTitle(currentNews.title) - .setDescription(currentNews.body) - .setImage(currentNews.imageURL || null) - .setTimestamp(parseInt(currentNews.updatedAt)) - .setFooter({ text: `Page ${page} of ${newsSorted.length} • ID: ${currentNews.id}` }) - .setColor(genColor(200)); - - await interaction.editReply({ embeds: [newsEmbed], components: [row] }); - await i.deferUpdate(); - }); - } -} diff --git a/src/commands/info/server.ts b/src/commands/info/server.ts deleted file mode 100644 index eaa0482..0000000 --- a/src/commands/info/server.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; -import serverEmbed from "../../utils/embeds/serverEmbed.js"; - -export default class Server { - data: SlashCommandSubcommandBuilder; - constructor() { - this.data = new SlashCommandSubcommandBuilder() - .setName("server") - .setDescription("Shows this server's info."); - } - - async run(interaction: ChatInputCommandInteraction) { - const embed = await serverEmbed({ guild: interaction.guild, roles: true }); - - await interaction.followUp({ embeds: [embed] }); - } -} diff --git a/src/commands/info/subscribe.ts b/src/commands/info/subscribe.ts deleted file mode 100644 index 0239fd2..0000000 --- a/src/commands/info/subscribe.ts +++ /dev/null @@ -1,58 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, type ChatInputCommandInteraction, DMChannel -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import { getNewsTable } from "../../utils/database.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; - -type SubscriptionType = string[]; - -export default class Subscribe { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("subscribe") - .setDescription("Subscribe to the news of the current server you're in."); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const newsTable = await getNewsTable(db);; - - const guild = interaction.guild; - const user = interaction.user; - - let subscriptions = await newsTable?.get(`${guild.id}.subscriptions`).then( - (subscriptions: any) => subscriptions as SubscriptionType ?? [] as SubscriptionType - ) - .catch(() => []) as SubscriptionType; - if (!subscriptions) subscriptions = []; - - const hasSub = subscriptions?.includes(user.id); - - const dmChannel = (await interaction.user.createDM().catch(() => null)) as DMChannel | null; - if (!dmChannel) return await interaction.followUp({ - embeds: [errorEmbed("You need to **enable DMs from server members** to subscribe to the news.")] - }); - const sendDms = await dmChannel?.send("You have updated the subscription status of \`" + guild.name + "\`.").catch(() => { }); - if (!sendDms) { - await newsTable.pull(`${guild.id}.subscriptions`, user.id); - return await interaction.followUp({ - embeds: [errorEmbed("You need to **enable DMs from server members** to subscribe to the news.")] - }); - } - - await newsTable[!hasSub ? "push" : "pull"](`${guild.id}.subscriptions`, user.id); - - const subscriptionEmbed = new EmbedBuilder() - .setTitle(`✅ • ${hasSub ? "Unsubscribed" : "Subscribed"} ${hasSub ? "from" : "to"} ${guild.name}`) - .setDescription(`You have ${hasSub ? "un" : ""}subscribed to the news of ${guild.name}.`) - .setColor(genColor(100)); - - await interaction.followUp({ embeds: [subscriptionEmbed] }); - } -} diff --git a/src/commands/info/user.ts b/src/commands/info/user.ts deleted file mode 100644 index fadb42c..0000000 --- a/src/commands/info/user.ts +++ /dev/null @@ -1,80 +0,0 @@ -import { - ColorResolvable, - SlashCommandSubcommandBuilder, - EmbedBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; -import { genColor, genRGBColor } from "../../utils/colorGen.js"; -import Vibrant from "node-vibrant"; -import sharp from "sharp"; - -export default class User { - data: SlashCommandSubcommandBuilder; - constructor() { - this.data = new SlashCommandSubcommandBuilder() - .setName("user") - .setDescription("Shows your (or another user's) info.") - .addUserOption((option) => option.setName("user").setDescription("Select the user.")); - } - - async run(interaction: ChatInputCommandInteraction) { - const user = interaction.options.getUser("user"); - const member = interaction.member; - const guild = interaction.guild; - - const id = user ? user.id : member.user.id; - const selectedMember = guild.members.cache.filter((member) => member.user.id === id).map((user) => user)[0]; - const avatarURL = selectedMember.displayAvatarURL(); - const selectedUser = selectedMember.user; - - // Sorting the roles - const guildRoles = guild.roles.cache.filter((role) => selectedMember.roles.cache.has(role.id)); - const memberRoles = [...guildRoles].sort((role1, role2) => role2[1].position - role1[1].position); - memberRoles.pop(); - const rolesOrRole = memberRoles.length === 1 ? "role" : "roles"; - - let embed = new EmbedBuilder() - .setAuthor({ - name: `• ${selectedMember.nickname == null ? selectedUser.username : selectedMember.nickname}${selectedUser.discriminator == "0" ? "" : `#${selectedUser.discriminator}` - }`, - iconURL: avatarURL, - }) - .setFields( - { - name: selectedUser.bot === false ? "👤 • User info" : "🤖 • Bot info", - value: [ - `**Username**: ${selectedUser.username}`, - `**Display name**: ${selectedUser.displayName === selectedUser.username ? "*None*" : selectedUser.displayName - }`, - `**Created on** `, - ].join("\n"), - }, - { - name: "👥 • Member info", - value: `**Joined on** `, - } - ) - .setFooter({ text: `User ID: ${selectedMember.id}` }) - .setThumbnail(avatarURL) - .setColor(genColor(200)); - - try { - const imageBuffer = await (await fetch(avatarURL)).arrayBuffer(); - const image = sharp(imageBuffer).toFormat("jpg"); - const { r, g, b } = (await new Vibrant(await image.toBuffer()).getPalette()).Vibrant; - embed.setColor(genRGBColor(r, g, b) as ColorResolvable); - } catch { } - - if (memberRoles.length != 0) { - embed.addFields({ - name: `🎭 • ${guildRoles.filter((role) => selectedMember.roles.cache.has(role.id)).size - 1} ${rolesOrRole}`, - value: `${memberRoles - .slice(0, 5) - .map((role) => `<@&${role[1].id}>`) - .join(", ")}${memberRoles.length > 5 ? ` **and ${memberRoles.length - 5} more**` : ""}`, - }); - } - - await interaction.followUp({ embeds: [embed] }); - } -} diff --git a/src/commands/leveling/leaderboard.ts b/src/commands/leveling/leaderboard.ts deleted file mode 100644 index 233a4d2..0000000 --- a/src/commands/leveling/leaderboard.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { SlashCommandSubcommandBuilder, EmbedBuilder, type ChatInputCommandInteraction } from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import { getLevelingTable, getSettingsTable } from "../../utils/database.js"; -import { BASE_EXP_FOR_NEW_LEVEL, DIFFICULTY_MULTIPLIER } from "../../events/leveling.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; - -export default class Leaderboard { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("leaderboard") - .setDescription("Shows the server's leaderboard in levels."); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const settingsTable = await getSettingsTable(db); - const levelingTable = await getLevelingTable(db); - - // Under maintenance - return await interaction.followUp({ - embeds: [errorEmbed("This command is under maintenance.")] - }); - - const guild = interaction.guild; - const levelEnabled = await settingsTable?.get(`${guild.id}.leveling.enabled`).catch(() => { }); - const levels = await levelingTable?.get(`${guild.id}`).catch(() => { }); - const levelKeys = Object.keys(levels) - const convertLevelsAndExpToExp = (levels: any) => { - const exp = Object.keys(levels).map(level => levels[level].exp); - return exp.reduce((a, b) => a + b); - }; - - if (!levelEnabled) - return await interaction.followUp({ embeds: [errorEmbed("Leveling is disabled for this server.")] }); - - const levelUpEmbed = new EmbedBuilder() - .setTitle("⚡ • Top 10 active members") - .setDescription( - levelKeys.slice(0, 10).map(level => - `#${Object.keys(levels).indexOf(level) + 1} • <@${level}>\n**Level ${levels[level].levels}** - Next Level: ${levels[level].levels + 1}\n**Exp**: ${levels[level].exp}/${Math.floor(BASE_EXP_FOR_NEW_LEVEL * DIFFICULTY_MULTIPLIER * (levels[level].levels + 1))} until level up` - ).join("\n\n") - ) - .setColor(genColor(200)) - .setTimestamp(); - - await interaction.followUp({ embeds: [levelUpEmbed] }); - } -} diff --git a/src/commands/leveling/level.ts b/src/commands/leveling/level.ts deleted file mode 100644 index ddd5c21..0000000 --- a/src/commands/leveling/level.ts +++ /dev/null @@ -1,128 +0,0 @@ -import { - ColorResolvable, - SlashCommandSubcommandBuilder, - EmbedBuilder, - type ChatInputCommandInteraction -} from "discord.js"; -import { genColor, genRGBColor } from "../../utils/colorGen.js"; -import Vibrant from "node-vibrant"; -import sharp from "sharp"; -import { getLevelingTable, getSettingsTable } from "../../utils/database.js"; -import { BASE_EXP_FOR_NEW_LEVEL, DIFFICULTY_MULTIPLIER } from "../../events/leveling.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; -import { Reward } from "../settings/leveling/rewards.js"; - -export default class Level { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("level") - .setDescription("Shows your (or another user's) level.") - .addUserOption((option) => option.setName("user").setDescription("Select the user.")); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const settingsTable = await getSettingsTable(db); - const levelingTable = await getLevelingTable(db); - - const user = interaction.options.getUser("user"); - const member = interaction.member; - const guild = interaction.guild; - - const id = user ? user.id : member.user.id; - const selectedMember = guild.members.cache.filter((member) => member.user.id === id).map((user) => user)[0]; - const avatarURL = selectedMember.displayAvatarURL(); - - const levelEnabled = await settingsTable?.get(`${guild.id}.leveling.enabled`).then( - (data) => { - if (!data) return false; - return Boolean(data); - } - ).catch(() => false); - if (!levelEnabled) { - return await interaction.followUp({ - embeds: [errorEmbed("Leveling is disabled for this server.")] - }); - } - - const { exp, levels } = await levelingTable?.get(`${guild.id}.${selectedMember.id}`).then( - (data) => { - if (!data) return { exp: 0, level: 0 }; - return { exp: Number(data.exp), levels: Number(data.levels) }; - } - ).catch(() => { - return { exp: 0, levels: 0 }; - }); - const formattedExp = exp?.toLocaleString("en-US"); - - if (!exp && !levels) { - await levelingTable.set(`${guild.id}.${selectedMember.id}`, { - levels: 0, - exp: 0 - }); - } - - let rewards = []; - let nextReward = null; - const levelRewards = await settingsTable?.get(`${interaction.guild.id}.leveling.rewards`).then( - (data) => { - if (!data) return [] as Reward[] ?? [] as Reward[]; - return data as Reward[] ?? [] as Reward[]; - } - ).catch(() => [] as Reward[]); - - for (const { roleId, level } of levelRewards) { - const role = await interaction.guild.roles.fetch(roleId).catch(() => { }); - const reward = { roleId, level }; - - if (levels < level) { - if (nextReward) break; - nextReward = reward; - break; - } - rewards.push(role); - } - - const expUntilLevelup = Math.floor(BASE_EXP_FOR_NEW_LEVEL * DIFFICULTY_MULTIPLIER * ((levels ?? 0) + 1)); - const formattedExpUntilLevelup = expUntilLevelup?.toLocaleString("en-US"); - const levelUpEmbed = new EmbedBuilder() - .setFields([ - { - name: `⚡ • Level ${levels ?? 0}`, - value: [ - `**Exp**: ${formattedExp ?? 0}/${formattedExpUntilLevelup} until level up`, - `**Next Level**: ${(levels ?? 0) + 1}` - ].join("\n") - }, - { - name: `🎁 • ${rewards.length} Rewards`, - value: [ - `${rewards.length > 0 ? rewards.map(reward => `<@&${reward.id}>`).join(" ") : "No rewards unlocked" - }`, - nextReward ? `**Upcoming reward**: <@&${nextReward.roleId}>` : "**Upcoming reward**: *Cricket, cricket, cricket* - Looks like you claimed everything!" - ].join("\n") - } - ]) - .setAuthor({ - name: `• ${selectedMember.user.username}`, - iconURL: avatarURL - }) - .setThumbnail(avatarURL) - .setColor(genColor(200)) - .setTimestamp(); - - try { - const imageBuffer = await (await fetch(avatarURL)).arrayBuffer(); - const image = sharp(imageBuffer).toFormat("jpg"); - const { r, g, b } = (await new Vibrant(await image.toBuffer()).getPalette()).Vibrant; - levelUpEmbed.setColor(genRGBColor(r, g, b) as ColorResolvable); - } catch { } - - await interaction.followUp({ embeds: [levelUpEmbed] }); - } -} diff --git a/src/commands/leveling/rewards.ts b/src/commands/leveling/rewards.ts deleted file mode 100644 index 513f539..0000000 --- a/src/commands/leveling/rewards.ts +++ /dev/null @@ -1,66 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, - Role, -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { getSettingsTable } from "../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export type Reward = { - roleId: string, - level: number, -} - -export default class Rewards { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("rewards") - .setDescription("Sets/gets reward roles for each level -> No options = shows."); - } - - async run(interaction: ChatInputCommandInteraction) { - // List the rewards - const rewards = await this.getRewards(interaction.guild.id).then(rewards => rewards.sort((a, b) => a.level - b.level)) as Reward[]; - if (rewards.length == 0) { - return await interaction.followUp({ - embeds: [errorEmbed("There are no rewards set for this server.")] - }); - } - - const rewardsEmbed = new EmbedBuilder() - .setTitle("🎁 • Rewards") - .setDescription("Here are the rewards in this server.") - .setColor(genColor(100)); - - for (const { roleId, level } of rewards) { - if (!roleId) continue; - rewardsEmbed.addFields([{ - name: `Level ${level}`, - value: `<@&${roleId}>`, - }]); - } - - return await interaction.followUp({ - embeds: [rewardsEmbed] - }); - } - - async getRewards(guildId: string): Promise { - const settingsTable = await getSettingsTable(this.db); - const rewards = new Promise((resolve, reject) => { - settingsTable?.get(`${guildId}.leveling.rewards`).then(rewards => { - if (!rewards) return resolve([]); - resolve(rewards); - }).catch(() => { - resolve([]); - }); - }); - return rewards as Promise; - } -} diff --git a/src/commands/moderation/Ban.ts b/src/commands/moderation/Ban.ts new file mode 100644 index 0000000..0dbc4de --- /dev/null +++ b/src/commands/moderation/Ban.ts @@ -0,0 +1,73 @@ +import { + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import ms from "ms"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; +import { scheduleUnban } from "../../utils/unbanScheduler"; + +export default class Ban { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("ban") + .setDescription("Bans a user.") + .addUserOption(user => + user.setName("user").setDescription("The user that you want to ban.").setRequired(true) + ) + .addStringOption(string => string.setName("reason").setDescription("The reason for the ban.")) + .addStringOption(string => + string.setName("duration").setDescription("The duration of the ban (e.g 2mo, 1y).") + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user")!; + const guild = interaction.guild!; + const duration = interaction.options.getString("duration"); + const reason = interaction.options.getString("reason"); + if ( + await errorCheck( + PermissionsBitField.Flags.BanMembers, + { interaction, user, action: "Ban" }, + { allErrors: true, botError: true, ownerError: true }, + "Ban Members" + ) + ) + return; + + if ((await guild.bans.fetch()).get(user.id)) + return await errorEmbed( + interaction, + `You can't ban ${user.displayName}.`, + "This user is already banned." + ); + + let expiresAt: number | undefined; + if (duration) { + const durationMs = ms(duration); + if (!durationMs || durationMs <= 0) + return await errorEmbed( + interaction, + `You can't ban ${user.displayName} temporarily.`, + "The duration is invalid." + ); + + expiresAt = Date.now() + durationMs; + scheduleUnban(interaction.client, guild.id, user.id, interaction.member!.user.id, durationMs); + } + + try { + await guild.members.ban(user.id, { reason: reason ?? undefined }); + } catch (err) { + console.error("Failed to ban user:", err); + } + + await modEmbed( + { interaction, user, action: "Banned", duration, dm: true, dbAction: "BAN", expiresAt }, + reason + ); + } +} diff --git a/src/commands/moderation/Cases.ts b/src/commands/moderation/Cases.ts new file mode 100644 index 0000000..021d805 --- /dev/null +++ b/src/commands/moderation/Cases.ts @@ -0,0 +1,92 @@ +import { + EmbedBuilder, + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../../utils/colorGen"; +import { getModeration, listUserModeration } from "../../utils/database/moderation"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { randomise } from "../../utils/randomise"; + +export default class Cases { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("cases") + .setDescription("Moderation cases in a server.") + .addUserOption(user => + user.setName("user").setDescription("The user that you want to see.").setRequired(true) + ) + .addStringOption(string => + string.setName("id").setDescription("The ID of a specific moderation case you want to see.") + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const actionsEmojis: { [key: string]: string } = { + WARN: "⚠️", + MUTE: "🔇", + KICK: "📤", + BAN: "🔨" + }; + + const nothingMsg = [ + "Nothing to see here...", + "Ayay, no cases on this horizon cap'n!", + "Clean as a whistle!", + "0+0=?" + ]; + + const guild = interaction.guild!; + if ( + !guild.members.cache + .get(interaction.user.id) + ?.permissions.has(PermissionsBitField.Flags.ModerateMembers) + ) + return await errorEmbed( + interaction, + "You can't execute this command.", + "You need the **Moderate Members** permission." + ); + + const user = interaction.options.getUser("user")!; + // const warns = listUserModeration(guild.id, user.id, "WARN"); + // const mutes = listUserModeration(guild.id, user.id, "MUTE"); + // const kicks = listUserModeration(guild.id, user.id, "KICK"); + // const bans = listUserModeration(guild.id, user.id, "BAN"); + let actionID = interaction.options.getString("id"); + if (actionID && actionID?.startsWith("#")) actionID = actionID.slice(1); + const actions = actionID + ? getModeration(guild.id, user.id, actionID) + : listUserModeration(guild.id, user.id); + + const embed = new EmbedBuilder() + .setAuthor({ name: `• Cases of ${user.displayName}`, iconURL: user.displayAvatarURL() }) + .setFields( + actions.length > 0 + ? actions.map(action => { + const actionValues = [ + `**Moderator**: <@${action.moderator}>`, + action.reason ? `**Reason**: ${action.reason}` : "*No reason provided*", + `-# **Time of action**: ` + ]; + + return { + name: `${actionsEmojis[action.type]} • ${action.type} #${action.id}`, // Include durations ? needs to add a db column + value: actionValues.join("\n") + }; + }) + : [ + { + name: `💨 • ${randomise(nothingMsg)}`, + value: "*No actions have been taken on this user*" + } + ] + ) + .setFooter({ text: `User ID: ${user.id}` }) + .setColor(genColor(200)); + + await interaction.reply({ embeds: [embed] }); + } +} diff --git a/src/commands/moderation/Clear.ts b/src/commands/moderation/Clear.ts new file mode 100644 index 0000000..2332999 --- /dev/null +++ b/src/commands/moderation/Clear.ts @@ -0,0 +1,85 @@ +import { + ChannelType, + EmbedBuilder, + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../../utils/colorGen"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { logChannel } from "../../utils/logChannel"; + +export default class Clear { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("clear") + .setDescription("Clears messages.") + .addNumberOption(number => + number + .setName("amount") + .setDescription("The amount of messages that you want to clear (maximum is 100).") + .setRequired(true) + ) + .addChannelOption(channel => + channel + .setName("channel") + .setDescription("The channel that has the messages that you want to clear.") + .addChannelTypes( + ChannelType.GuildText, + ChannelType.PublicThread, + ChannelType.PrivateThread, + ChannelType.GuildVoice + ) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const guild = interaction.guild!; + if ( + !guild.members.cache + .get(interaction.user.id) + ?.permissions.has(PermissionsBitField.Flags.ManageMessages) + ) + return await errorEmbed( + interaction, + "You can't execute this command.", + "You need the **Manage Messages** permission." + ); + + const amount = interaction.options.getNumber("amount")!; + if (amount > 100) + return await errorEmbed(interaction, "You can only clear up to 100 messages at a time."); + + if (amount < 1) return await errorEmbed(interaction, "You must clear at least 1 message."); + + const channelOption = interaction.options.getChannel("channel")!; + const channel = guild.channels.cache.get(interaction.channel?.id ?? channelOption.id)!; + const embed = new EmbedBuilder() + .setAuthor({ name: `Cleared ${amount} message${amount == 1 ? "" : "s"}.` }) + .setDescription( + [ + `**Moderator**: ${interaction.user.displayName}`, + `**Channel**: ${channelOption ?? `<#${channel.id}>`}` + ].join("\n") + ) + .setColor(genColor(100)); + + if ( + channel.type == ChannelType.GuildText && + ChannelType.PublicThread && + ChannelType.PrivateThread && + ChannelType.GuildVoice + ) + try { + channel == interaction.channel + ? await channel.bulkDelete(amount + 1, true) + : await channel.bulkDelete(amount, true); + } catch (error) { + console.error(error); + } + + await logChannel(guild, embed); + await interaction.reply({ embeds: [embed] }); + } +} diff --git a/src/commands/moderation/Delwarn.ts b/src/commands/moderation/Delwarn.ts new file mode 100644 index 0000000..88af050 --- /dev/null +++ b/src/commands/moderation/Delwarn.ts @@ -0,0 +1,70 @@ +import { + DMChannel, + EmbedBuilder, + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../../utils/colorGen"; +import { listUserModeration, removeModeration } from "../../utils/database/moderation"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { errorCheck } from "../../utils/embeds/modEmbed"; +import { logChannel } from "../../utils/logChannel"; + +export default class Delwarn { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("delwarn") + .setDescription("Removes a warning from a user.") + .addUserOption(user => + user + .setName("user") + .setDescription("The user that you want to free from the warning.") + .setRequired(true) + ) + .addNumberOption(number => + number.setName("id").setDescription("The id of the warn.").setRequired(true) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user")!; + const guild = interaction.guild!; + const name = user.displayName; + const id = interaction.options.getNumber("id"); + const warns = listUserModeration(guild.id, user.id, "WARN"); + const newWarns = warns.filter(warn => warn.id != `${id}`); + if ( + await errorCheck( + PermissionsBitField.Flags.ModerateMembers, + { interaction, user, action: "Remove a warn" }, + { allErrors: true, botError: false }, + "Moderate Members" + ) + ) + return; + + if (newWarns.length == warns.length) + return await errorEmbed(interaction, `There is no warn with the id of ${id}.`); + + const embed = new EmbedBuilder() + .setAuthor({ name: `• Removed a warning from ${name}`, iconURL: user.displayAvatarURL() }) + .setDescription(`**Moderator**: ${interaction.user.displayName}`) + .setFooter({ text: `User ID: ${user.id}` }) + .setColor(genColor(100)); + + await logChannel(guild, embed); + try { + removeModeration(guild.id, `${id}`); + } catch (error) { + console.error(error); + } + + await interaction.reply({ embeds: [embed] }); + const dmChannel = (await user.createDM().catch(() => null)) as DMChannel | null; + if (!dmChannel) return; + if (user.bot) return; + await dmChannel.send({ embeds: [embed.setTitle("Your warning has been removed.")] }); + } +} diff --git a/src/commands/moderation/Kick.ts b/src/commands/moderation/Kick.ts new file mode 100644 index 0000000..c67bae8 --- /dev/null +++ b/src/commands/moderation/Kick.ts @@ -0,0 +1,50 @@ +import { + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; + +export default class Kick { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("kick") + .setDescription("Kicks a user.") + .addUserOption(user => + user.setName("user").setDescription("The user that you want to kick.").setRequired(true) + ) + .addStringOption(string => + string.setName("reason").setDescription("The reason for the kick.") + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user")!; + if ( + await errorCheck( + PermissionsBitField.Flags.KickMembers, + { interaction, user, action: "Kick" }, + { allErrors: true, botError: true, ownerError: true, outsideError: true }, + "Kick Members" + ) + ) + return; + + if (!interaction.guild?.members.cache.get(user.id)) + return await errorEmbed( + interaction, + `You can't kick ${user.displayName}.`, + "This user is not in the server." + ); + + const reason = interaction.options.getString("reason"); + await interaction.guild?.members.cache + .get(user.id) + ?.kick(reason ?? undefined) + .catch(error => console.error(error)); + + await modEmbed({ interaction, user, action: "Kicked", dm: true, dbAction: "KICK" }, reason); + } +} diff --git a/src/commands/moderation/Lock.ts b/src/commands/moderation/Lock.ts new file mode 100644 index 0000000..7197865 --- /dev/null +++ b/src/commands/moderation/Lock.ts @@ -0,0 +1,81 @@ +import { + ChannelType, + EmbedBuilder, + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../../utils/colorGen"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { logChannel } from "../../utils/logChannel"; + +export default class Lock { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("lock") + .setDescription("Locks a channel.") + .addChannelOption(channel => + channel + .setName("channel") + .setDescription("The channel that you want to lock.") + .addChannelTypes( + ChannelType.GuildText, + ChannelType.PublicThread, + ChannelType.PrivateThread, + ChannelType.GuildVoice + ) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const guild = interaction.guild!; + if ( + !guild.members.cache + .get(interaction.user.id) + ?.permissions.has(PermissionsBitField.Flags.ManageRoles) + ) + return await errorEmbed( + interaction, + "You can't execute this command.", + "You need the **Manage Roles** permission." + ); + + const channelOption = interaction.options.getChannel("channel")!; + const channel = guild.channels.cache.get(interaction.channel?.id ?? channelOption.id)!; + if (!channel.permissionsFor(guild.id)?.has("SendMessages")) + return await errorEmbed( + interaction, + "You can't execute this command.", + "The channel is already locked." + ); + + const embed = new EmbedBuilder() + .setAuthor({ name: `Locked a channel.` }) + .setDescription( + [ + `**Moderator**: ${interaction.user.displayName}`, + `**Channel**: ${channelOption ?? `<#${channel.id}>`}` + ].join("\n") + ) + .setColor(genColor(100)); + + if ( + channel.type == ChannelType.GuildText && + ChannelType.PublicThread && + ChannelType.PrivateThread && + ChannelType.GuildVoice + ) + channel.permissionOverwrites + .create(guild.id, { + SendMessages: false, + SendMessagesInThreads: false, + CreatePublicThreads: false, + CreatePrivateThreads: false + }) + .catch(error => console.error(error)); + + await logChannel(guild, embed); + await interaction.reply({ embeds: [embed] }); + } +} diff --git a/src/commands/moderation/Mute.ts b/src/commands/moderation/Mute.ts new file mode 100644 index 0000000..b32ad71 --- /dev/null +++ b/src/commands/moderation/Mute.ts @@ -0,0 +1,66 @@ +import { + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import ms from "ms"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; + +export default class Mute { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("mute") + .setDescription("Mutes a user.") + .addUserOption(user => + user.setName("user").setDescription("The user that you want to mute.").setRequired(true) + ) + .addStringOption(string => + string + .setName("duration") + .setDescription("The duration of the mute (e.g 30m, 1d, 2h).") + .setRequired(true) + ) + .addStringOption(string => + string.setName("reason").setDescription("The reason for the mute.") + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user")!; + const duration = interaction.options.getString("duration")!; + const reason = interaction.options.getString("reason"); + if ( + await errorCheck( + PermissionsBitField.Flags.ModerateMembers, + { interaction, user, action: "Mute" }, + { allErrors: true, botError: true, ownerError: true }, + "Moderate Members" + ) + ) + return; + + if (!ms(duration) || ms(duration) > ms("28d") || ms(duration) <= 0) + return await errorEmbed( + interaction, + `You can't mute ${user.displayName}.`, + "The duration is invalid or is above the 28 day limit." + ); + + const time = new Date( + Date.parse(new Date().toISOString()) + Date.parse(new Date(ms(duration)).toISOString()) + ).toISOString(); + + await interaction.guild?.members.cache + .get(user.id) + ?.edit({ communicationDisabledUntil: time, reason: reason ?? undefined }) + .catch(error => console.error(error)); + + await modEmbed( + { interaction, user, action: "Muted", duration, dm: true, dbAction: "MUTE" }, + reason, + true + ); + } +} diff --git a/src/commands/moderation/Slowdown.ts b/src/commands/moderation/Slowdown.ts new file mode 100644 index 0000000..3451595 --- /dev/null +++ b/src/commands/moderation/Slowdown.ts @@ -0,0 +1,89 @@ +import { + ChannelType, + EmbedBuilder, + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import ms from "ms"; +import { genColor } from "../../utils/colorGen"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { logChannel } from "../../utils/logChannel"; + +export default class Slowdown { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("slowdown") + .setDescription("Slows a channel down.") + .addStringOption(string => + string + .setName("time") + .setDescription( + "Time to slow the channel down to (e.g 30m, 1d, 2h). 0 to remove slowdown." + ) + .setRequired(true) + ) + .addStringOption(string => + string.setName("reason").setDescription("The reason for the slowdown.") + ) + .addChannelOption(channel => + channel + .setName("channel") + .setDescription("The channel that you want to slowdown.") + .addChannelTypes( + ChannelType.GuildText, + ChannelType.PublicThread, + ChannelType.PrivateThread, + ChannelType.GuildVoice + ) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const guild = interaction.guild!; + if ( + !guild.members.cache + .get(interaction.user.id) + ?.permissions.has(PermissionsBitField.Flags.ManageChannels) + ) + return await errorEmbed( + interaction, + "You can't execute this command.", + "You need the **Manage Channels** permission." + ); + + const time = interaction.options.getString("time")!; + const reason = interaction.options.getString("reason"); + const channelOption = interaction.options.getChannel("channel")!; + const channel = guild.channels.cache.get(interaction.channel?.id ?? channelOption.id)!; + let title = `Set a slowdown of \`${channelOption ?? `${channel.name}`}\` to ${ms(ms(time), { + long: true + })}.`; + if (!ms(time)) title = `Removed the slowdown from \`${channelOption ?? `${channel.name}`}\`.`; + + const embed = new EmbedBuilder() + .setAuthor({ name: title }) + .setDescription( + [ + `**Moderator**: ${interaction.user.displayName}`, + reason ? `**Reason**: ${reason}` : "*No reason provided*", + `**Channel**: ${channelOption ?? `<#${channel.id}>**`}` + ].join("\n") + ) + .setColor(genColor(100)); + + if ( + channel.type == ChannelType.GuildText && + ChannelType.PublicThread && + ChannelType.PrivateThread && + ChannelType.GuildVoice + ) + await channel + .setRateLimitPerUser(ms(time) / 1000, interaction.options.getString("reason")!) + .catch(error => console.error(error)); + + await logChannel(guild, embed); + await interaction.reply({ embeds: [embed] }); + } +} diff --git a/src/commands/moderation/Unban.ts b/src/commands/moderation/Unban.ts new file mode 100644 index 0000000..a8d5d8e --- /dev/null +++ b/src/commands/moderation/Unban.ts @@ -0,0 +1,52 @@ +import { + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; + +export default class Unban { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("unban") + .setDescription("Unbans a user.") + .addStringOption(string => + string + .setName("id") + .setDescription("The ID of the user that you want to unban.") + .setRequired(true) + ) + .addStringOption(string => + string.setName("reason").setDescription("The reason for the unban.") + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const id = interaction.options.getString("id")!; + const reason = interaction.options.getString("reason")!; + const guild = interaction.guild!; + const target = (await guild.bans.fetch()).get(id)?.user!; + + if ( + await errorCheck( + PermissionsBitField.Flags.BanMembers, + { interaction, user: target, action: "Unban" }, + { allErrors: false, botError: true, ownerError: true }, + "Ban Members" + ) + ) + return; + + if (!target) + return await errorEmbed( + interaction, + "You can't unban this user.", + "The user was never banned." + ); + + await guild.members.unban(id, reason ?? undefined).catch(error => console.error(error)); + await modEmbed({ interaction, user: target, action: "Unbanned" }, reason); + } +} diff --git a/src/commands/moderation/Unlock.ts b/src/commands/moderation/Unlock.ts new file mode 100644 index 0000000..edba7ee --- /dev/null +++ b/src/commands/moderation/Unlock.ts @@ -0,0 +1,81 @@ +import { + ChannelType, + EmbedBuilder, + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../../utils/colorGen"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { logChannel } from "../../utils/logChannel"; + +export default class Unlock { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("unlock") + .setDescription("Unlocks a channel.") + .addChannelOption(channel => + channel + .setName("channel") + .setDescription("The channel that you want to unlock.") + .addChannelTypes( + ChannelType.GuildText, + ChannelType.PublicThread, + ChannelType.PrivateThread, + ChannelType.GuildVoice + ) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const guild = interaction.guild!; + if ( + !guild.members.cache + .get(interaction.user.id) + ?.permissions.has(PermissionsBitField.Flags.ManageRoles) + ) + return await errorEmbed( + interaction, + "You can't execute this command.", + "You need the **Manage Roles** permission." + ); + + const channelOption = interaction.options.getChannel("channel")!; + const channel = guild.channels.cache.get(interaction.channel?.id ?? channelOption.id)!; + if (channel.permissionsFor(guild.id)?.has("SendMessages")) + return await errorEmbed( + interaction, + "You can't execute this command.", + "The channel is not locked." + ); + + const embed = new EmbedBuilder() + .setAuthor({ name: `Unlocked a channel.` }) + .setDescription( + [ + `**Moderator**: ${interaction.user.displayName}`, + `**Channel**: ${channelOption ?? `<#${channel.id}>`}` + ].join("\n") + ) + .setColor(genColor(100)); + + if ( + channel.type == ChannelType.GuildText && + ChannelType.PublicThread && + ChannelType.PrivateThread && + ChannelType.GuildVoice + ) + channel.permissionOverwrites + .create(guild.id, { + SendMessages: null, + SendMessagesInThreads: null, + CreatePublicThreads: null, + CreatePrivateThreads: null + }) + .catch(error => console.error(error)); + + await logChannel(guild, embed); + await interaction.reply({ embeds: [embed] }); + } +} diff --git a/src/commands/moderation/Unmute.ts b/src/commands/moderation/Unmute.ts new file mode 100644 index 0000000..622e95b --- /dev/null +++ b/src/commands/moderation/Unmute.ts @@ -0,0 +1,43 @@ +import { + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; + +export default class Unmute { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("unmute") + .setDescription("Unmutes a user.") + .addUserOption(user => + user.setName("user").setDescription("The user that you want to unmute.").setRequired(true) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user")!; + const target = interaction.guild?.members.cache.get(user.id)!; + if ( + await errorCheck( + PermissionsBitField.Flags.ModerateMembers, + { interaction, user, action: "Unmute" }, + { allErrors: false, botError: true }, + "Moderate Members" + ) + ) + return; + + if (!target.communicationDisabledUntil) + return await errorEmbed( + interaction, + "You can't unmute this user.", + "The user was never muted." + ); + + await target.edit({ communicationDisabledUntil: null }).catch(error => console.error(error)); + await modEmbed({ interaction, user, action: "Unmuted" }); + } +} diff --git a/src/commands/moderation/Warn.ts b/src/commands/moderation/Warn.ts new file mode 100644 index 0000000..a458908 --- /dev/null +++ b/src/commands/moderation/Warn.ts @@ -0,0 +1,49 @@ +import { + PermissionsBitField, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; + +export default class Warn { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("warn") + .setDescription("Warns a user.") + .addUserOption(user => + user.setName("user").setDescription("The user that you want to warn.").setRequired(true) + ) + .addStringOption(string => + string.setName("reason").setDescription("The reason for the warn.") + ) + .addBooleanOption(bool => + bool + .setName("show_moderator") + .setDescription( + "Inform the warned user of the moderator that took the action. Defaults to false." + ) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user")!; + const reason = interaction.options.getString("reason"); + const showModerator = interaction.options.getBoolean("show_moderator") ?? false; + if ( + await errorCheck( + PermissionsBitField.Flags.ModerateMembers, + { interaction, user, action: "Warn" }, + { allErrors: true, botError: false, ownerError: true, outsideError: true }, + "Moderate Members" + ) + ) + return; + + await modEmbed( + { interaction, user, action: "Warned", dm: true, dbAction: "WARN" }, + reason, + showModerator + ); + } +} diff --git a/src/commands/moderation/ban.ts b/src/commands/moderation/ban.ts deleted file mode 100644 index 6f6115b..0000000 --- a/src/commands/moderation/ban.ts +++ /dev/null @@ -1,86 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, TextChannel, DMChannel -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; -import { getSettingsTable } from "../../utils/database.js"; - -export default class Ban { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("ban") - .setDescription("Bans a user.") - .addUserOption(option => option - .setName("user") - .setDescription("The user that you want to ban.") - .setRequired(true) - ) - .addStringOption(option => option - .setName("reason") - .setDescription("The reason for the ban.") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const user = interaction.options.getUser("user"); - const reason = interaction.options.getString("reason"); - const members = interaction.guild.members.cache; - const member = members.get(interaction.member.user.id); - const selectedMember = members.get(user.id); - const name = selectedMember.nickname ?? user.username; - - const dmChannel = (await user.createDM().catch(() => null)) as DMChannel | null;; - const banEmbed = new EmbedBuilder() - .setTitle(`✅ • Banned ${user.username}`) - .setDescription([ - `**Moderator**: <@${interaction.user.id}>`, - `**Reason**: ${reason ?? "No reason provided"}` - ].join("\n")) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(100)); - - const embedDM = new EmbedBuilder() - .setTitle(`🔨 • You were banned`) - .setDescription([ - `**Moderator**: ${interaction.user.username}`, - `**Reason**: ${reason ?? "No reason provided"}` - ].join("\n")) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(0)); - - if (!member.permissions.has(PermissionsBitField.Flags.BanMembers)) - return await interaction.followUp({ embeds: [errorEmbed("You need the **Ban Members** permission to execute this command.")] }); - if (selectedMember === member) - return await interaction.followUp({ embeds: [errorEmbed("You can't ban yourself")] }); - if (selectedMember.user.id === interaction.client.user.id) - return await interaction.followUp({ embeds: [errorEmbed("You can't ban Nebula.")] }); - if (!selectedMember.manageable) - return await interaction.followUp({ embeds: [errorEmbed(`You can't ban ${name}, because they have a higher role position than Nebula.`)] }); - if (member.roles.highest.position < selectedMember.roles.highest.position) - return await interaction.followUp({ embeds: [errorEmbed(`You can't ban ${name}, because they have a higher role position than you.`)] }); - - const db = this.db; - const settingsTable = await getSettingsTable(db); - const logChannel = await settingsTable?.get(`${interaction.guild.id}.logChannel`).then( - (channel: string | null) => channel ?? null - ).catch(() => null); - if (logChannel) { - const channel = (await interaction.guild.channels.cache.get(logChannel).fetch()) as TextChannel; - await channel.send({ embeds: [banEmbed] }); - } - - if (dmChannel) await dmChannel.send({ embeds: [embedDM] }); - await selectedMember.ban({ reason: reason ?? undefined }); - await interaction.followUp({ embeds: [banEmbed] }); - } -} diff --git a/src/commands/moderation/kick.ts b/src/commands/moderation/kick.ts deleted file mode 100644 index 2a433cd..0000000 --- a/src/commands/moderation/kick.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, TextChannel, DMChannel, - Channel, ChannelType -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; -import { getSettingsTable } from "../../utils/database.js"; - -export default class Kick { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("kick") - .setDescription("Kicks a user.") - .addUserOption(option => option - .setName("user") - .setDescription("The user that you want to kick.") - .setRequired(true) - ) - .addStringOption(option => option - .setName("reason") - .setDescription("The reason for the kick.") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const user = interaction.options.getUser("user"); - const reason = interaction.options.getString("reason"); - const members = interaction.guild.members.cache; - const member = members.get(interaction.member.user.id); - const selectedMember = members.get(user.id); - const name = selectedMember.nickname ?? user.username; - - const dmChannel = (await user.createDM().catch(() => null)) as DMChannel | null;; - const kickEmbed = new EmbedBuilder() - .setTitle(`✅ • Kicked <@${user.id}>`) - .setDescription([ - `**Moderator**: <@${interaction.user.id}>`, - `**Reason**: ${reason ?? "No reason provided"}` - ].join("\n")) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(100)); - - const embedDM = new EmbedBuilder() - .setTitle(`👢 • You were kicked`) - .setDescription([ - `**Moderator**: ${interaction.user.username}`, - `**Reason**: ${reason ?? "No reason provided"}` - ].join("\n")) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(0)); - - if (!member.permissions.has(PermissionsBitField.Flags.KickMembers)) - return await interaction.followUp({ embeds: [errorEmbed("You need the **Kick Members** permission to execute this command.")] }); - if (selectedMember === member) - return await interaction.followUp({ embeds: [errorEmbed("You can't kick yourself.")] }); - if (selectedMember.user.id === interaction.client.user.id) - return await interaction.followUp({ embeds: [errorEmbed("You can't kick Nebula.")] }); - if (!selectedMember.manageable) - return await interaction.followUp({ embeds: [errorEmbed(`You can't kick ${name}, because they have a higher role position than Nebula.`)] }); - if (member.roles.highest.position < selectedMember.roles.highest.position) - return await interaction.followUp({ embeds: [errorEmbed(`You can't kick ${name}, because they have a higher role position than you.`)] }); - - const db = this.db; - const settingsTable = await getSettingsTable(db); - const logChannel = await settingsTable?.get(`${interaction.guild.id}.logChannel`).then( - (channel: string | null) => channel ?? null - ).catch(() => null); - if (logChannel) { - const channel = await interaction.guild.channels.cache.get(logChannel).fetch().then( - (channel: Channel | null) => { - if (channel.type != ChannelType.GuildText) return null; - return channel as TextChannel; - } - ).catch(() => null); - if (channel) await channel.send({ embeds: [kickEmbed] }); - } - - if (dmChannel) await dmChannel.send({ embeds: [embedDM] }); - await selectedMember.kick(reason ?? undefined); - await interaction.followUp({ embeds: [kickEmbed] }); - } -} diff --git a/src/commands/moderation/mute.ts b/src/commands/moderation/mute.ts deleted file mode 100644 index 9ac2d47..0000000 --- a/src/commands/moderation/mute.ts +++ /dev/null @@ -1,104 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, TextChannel, DMChannel, - Channel, ChannelType -} from "discord.js"; -import ms from "ms"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { getSettingsTable } from "../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export default class Mute { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("mute") - .setDescription("Mutes a user.") - .addUserOption(user => user - .setName("user") - .setDescription("The user that you want to mute.") - .setRequired(true) - ) - .addStringOption(string => string - .setName("duration") - .setDescription("The duration of the mute (e.g 30m, 1d, 2h)") - .setRequired(true) - ) - .addStringOption(string => string - .setName("reason") - .setDescription("The reason for the mute.") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const user = interaction.options.getUser("user"); - const duration = interaction.options.getString("duration"); - const members = interaction.guild.members.cache; - const member = members.get(interaction.member.user.id); - const selectedMember = members.get(user.id); - const name = selectedMember.nickname ?? user.username; - - const ISOduration = new Date(ms(duration)).toISOString(); - const time = new Date(Date.parse(new Date().toISOString()) + Date.parse(ISOduration)).toISOString(); - - const dmChannel = (await user.createDM().catch(() => null)) as DMChannel | null; - const muteEmbed = new EmbedBuilder() - .setTitle(`✅ • Muted ${user.username}`) - .setDescription([ - `**Moderator**: <@${member.id}>`, - `**Duration**: ${ms(ms(duration), { long: true })}`, - `**Reason**: ${interaction.options.getString("reason") ?? "No reason provided"}` - ].join("\n")) - .setFooter({ text: `User ID: ${user.id}` }) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setColor(genColor(100)); - const embedDM = new EmbedBuilder() - .setTitle(`🤐 • You were muted`) - .setDescription([ - `**Moderator**: ${member.user.username}`, - `**Duration**: ${ms(ms(duration), { long: true })}`, - `**Reason**: ${interaction.options.getString("reason") ?? "No reason provided"}` - ].join("\n")) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(0)); - - if (!member.permissions.has(PermissionsBitField.Flags.MuteMembers)) - return await interaction.followUp({ embeds: [errorEmbed("You need the **Mute Members** permission to execute this command.")] }); - if (selectedMember === member) - return await interaction.followUp({ embeds: [errorEmbed("You can't mute yourself.")] }); - if (selectedMember.user.bot) - return await interaction.followUp({ embeds: [errorEmbed(`You can't mute ${name}, because it's a bot.`)] }); - if (!selectedMember.manageable) - return await interaction.followUp({ embeds: [errorEmbed(`You can't mute ${name}, because they have a higher role position than Nebula.`)] }); - if (member.roles.highest.position < selectedMember.roles.highest.position) - return await interaction.followUp({ embeds: [errorEmbed(`You can't mute ${name}, because they have a higher role position than you.`)] }); - if (!ms(duration) || ms(duration) > ms("28d")) - return await interaction.followUp({ embeds: [errorEmbed("The duration is invalid or is above the 28 day limit.")] }); - - const db = this.db; - const settingsTable = await getSettingsTable(db); - const logChannel = await settingsTable?.get(`${interaction.guild.id}.logChannel`).then( - (channel: string | null) => channel - ).catch(() => null); - if (logChannel) { - const channel = await interaction.guild.channels.cache.get(logChannel).fetch().then( - (channel: Channel) => { - if (channel.type != ChannelType.GuildText) return null; - return channel as TextChannel; - } - ).catch(() => null); - if (channel) await channel.send({ embeds: [muteEmbed] }); - } - - if (dmChannel) await dmChannel.send({ embeds: [embedDM] }); - await selectedMember.edit({ communicationDisabledUntil: time }); - await interaction.followUp({ embeds: [muteEmbed] }); - } -} diff --git a/src/commands/moderation/purge.ts b/src/commands/moderation/purge.ts deleted file mode 100644 index 94f2e49..0000000 --- a/src/commands/moderation/purge.ts +++ /dev/null @@ -1,83 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - ChannelType, type ChatInputCommandInteraction, TextChannel, - Channel -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { getSettingsTable } from "../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export default class Purge { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("purge") - .setDescription("Purges messages.") - .addNumberOption((number) => - number.setName("amount").setDescription("The amount of messages that you want to purge.").setRequired(true) - ) - .addChannelOption((channel) => - channel - .setName("channel") - .setDescription("The channel that you want to purge (if not the current channel)") - .addChannelTypes(ChannelType.GuildText, ChannelType.PublicThread, ChannelType.PrivateThread) - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const amount = interaction.options.getNumber("amount"); - if (amount > 100) { - return await interaction.followUp({ - embeds: [errorEmbed("You can only purge up to 100 messages at a time.")], - }); - } - if (amount < 1) { - return await interaction.followUp({ - embeds: [errorEmbed("You must purge at least 1 message.")], - }); - } - const channelOption = interaction.options.getChannel("channel"); - const member = interaction.guild.members.cache.get(interaction.member.user.id); - const channel = interaction.guild.channels.cache.get(interaction.channel.id ?? channelOption.id); - - const purgeEmbed = new EmbedBuilder() - .setTitle(`✅ • Purged ${amount} messages.`) - .setDescription([ - `**Moderator**: <@${member.id}>`, - `**Channel**: ${channelOption ?? `<#${channel.id}>`}`, - ].join("\n")) - .setColor(genColor(100)); - - if (!member.permissions.has(PermissionsBitField.Flags.ManageMessages)) { - return await interaction.followUp({ - embeds: [errorEmbed("You need the **Manage Messages** permission to execute this command.")], - }); - } - if (channel.type === ChannelType.GuildText && ChannelType.PublicThread && ChannelType.PrivateThread) { - channel == interaction.channel - ? await channel.bulkDelete(amount + 1, true) - : await channel.bulkDelete(amount, true); - } - - const db = this.db; - const settingsTable = await getSettingsTable(db); - const logChannel = await settingsTable?.get(`${interaction.guild.id}.logChannel`).then( - (channel: string | null) => channel - ).catch(() => null); - if (logChannel) { - const channel = await interaction.guild.channels.cache.get(logChannel).fetch().then( - (channel: Channel) => { - if (channel.type != ChannelType.GuildText) return null; - return channel as TextChannel; - } - ).catch(() => null); - if (channel) await channel.send({ embeds: [purgeEmbed] }); - } - - await interaction.followUp({ embeds: [purgeEmbed] }); - } -} diff --git a/src/commands/moderation/unban.ts b/src/commands/moderation/unban.ts deleted file mode 100644 index 4633dd3..0000000 --- a/src/commands/moderation/unban.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - PermissionsBitField, EmbedBuilder, SlashCommandSubcommandBuilder, - type ChatInputCommandInteraction, TextChannel, DMChannel, - Channel, ChannelType -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { getSettingsTable } from "../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export default class Unban { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("unban") - .setDescription("Unbans a user.") - .addStringOption(string => string - .setName("user") - .setDescription("The ID of the user that you want to unban.") - .setRequired(true) - ) - } - - async run(interaction: ChatInputCommandInteraction) { - const userID = interaction.options.getString("user"); - const member = interaction.guild.members.cache.get(interaction.member.user.id); - const bannedMembers = interaction.guild.bans.cache; - const bannedMemberArray = bannedMembers.map(user => user.user); - const selectedBannedMember = bannedMemberArray.filter(user => user.id === userID)[0]; - - const dmChannel = (await selectedBannedMember.createDM().catch(() => null)) as DMChannel | null;; - const unbanEmbed = new EmbedBuilder() - .setTitle(`✅ • Unbanned ${member.user.username}`) - .setDescription([ - `**Moderator**: <@${member.user.id}>` - ].join("\n")) - .setAuthor({ name: member.user.username, iconURL: member.user.displayAvatarURL() }) - .setThumbnail(selectedBannedMember.displayAvatarURL()) - .setFooter({ text: `User ID: ${userID}` }) - .setColor(genColor(100)); - const embedDM = new EmbedBuilder() - .setTitle(`🤝 • You were unbanned`) - .setDescription([ - `**Moderator**: ${member.user.username}` - ].join("\n")) - .setAuthor({ name: member.user.username, iconURL: member.user.displayAvatarURL() }) - .setThumbnail(selectedBannedMember.displayAvatarURL()) - .setFooter({ text: `User ID: ${userID}` }) - .setColor(genColor(100)); - - if (!member.permissions.has(PermissionsBitField.Flags.BanMembers)) - return await interaction.followUp({ embeds: [errorEmbed("You need the **Ban Members** permission to execute this command.")] }); - if (selectedBannedMember == undefined) - return await interaction.followUp({ embeds: [errorEmbed("You can't unban this user because they were never banned.")] }); - - const db = this.db; - const settingsTable = await getSettingsTable(db); - const logChannel = await settingsTable?.get(`${interaction.guild.id}.logChannel`).then( - (channel: string | null) => channel - ).catch(() => null); - if (logChannel) { - const channel = await interaction.guild.channels.cache.get(logChannel).fetch().then( - (channel: Channel) => { - if (channel.type != ChannelType.GuildText) return null; - return channel as TextChannel; - } - ).catch(() => null); - if (channel) await channel.send({ embeds: [unbanEmbed] }); - } - - if (dmChannel) await dmChannel.send({ embeds: [embedDM] }); - await interaction.guild.members.unban(userID); - await interaction.followUp({ embeds: [unbanEmbed] }); - } -} diff --git a/src/commands/moderation/unmute.ts b/src/commands/moderation/unmute.ts deleted file mode 100644 index 72d16ab..0000000 --- a/src/commands/moderation/unmute.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, TextChannel, DMChannel, - Channel, ChannelType -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { getSettingsTable } from "../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export default class Unmute { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("unmute") - .setDescription("Unmutes a user.") - .addUserOption(user => user - .setName("user") - .setDescription("The user that you want to unmute.") - .setRequired(true) - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const user = interaction.options.getUser("user"); - const members = interaction.guild.members.cache; - const member = members.get(interaction.member.user.id); - const selectedMember = members.get(user.id); - const name = selectedMember.nickname ?? user.username; - - const dmChannel = (await user.createDM().catch(() => null)) as DMChannel | null;; - let unmuteEmbed = new EmbedBuilder() - .setTitle(`✅ • Unmuted ${user.username}`) - .setDescription([ - `**Moderator**: <@${interaction.user.id}>`, - `**Date**: ` - ].join("\n")) - .setAuthor({ name: `• ${name}`, iconURL: user.displayAvatarURL() }) - .setThumbnail(user.displayAvatarURL()) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(100)); - const embedDM = new EmbedBuilder() - .setTitle(`🤝 • You were unmuted`) - .setDescription([ - `**Moderator**: ${interaction.user.username}`, - `**Date**: ` - ].join("\n")) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(100)); - - if (!member.permissions.has(PermissionsBitField.Flags.MuteMembers)) - return await interaction.followUp({ embeds: [errorEmbed("You need the **Mute Members** permission to execute this command.")] }); - - const db = this.db; - const settingsTable = await getSettingsTable(db); - const logChannel = await settingsTable?.get(`${interaction.guild.id}.logChannel`).then( - (channel: string | null) => channel - ).catch(() => null); - if (logChannel) { - const channel = await interaction.guild.channels.cache.get(logChannel).fetch().then( - (channel: Channel) => { - if (channel.type != ChannelType.GuildText) return null; - return channel as TextChannel; - } - ).catch(() => null); - if (channel) await channel.send({ embeds: [unmuteEmbed] }); - } - - if (dmChannel) await dmChannel.send({ embeds: [embedDM] }); - await selectedMember.edit({ communicationDisabledUntil: null }); - await interaction.followUp({ embeds: [unmuteEmbed] }); - } -} diff --git a/src/commands/moderation/unwarn.ts b/src/commands/moderation/unwarn.ts deleted file mode 100644 index 1e69b0f..0000000 --- a/src/commands/moderation/unwarn.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, TextChannel, DMChannel, - ChannelType, Channel -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; -import { getModerationTable, getSettingsTable } from "../../utils/database.js"; - -export default class Unwarn { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("unwarn") - .setDescription("Warns a user.") - .addUserOption(user => user - .setName("user") - .setDescription("The user that you want to warn.") - .setRequired(true) - ) - .addNumberOption(string => string - .setName("id") - .setDescription("The id of the warn.") - .setRequired(true) - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const modTable = await getModerationTable(db); - - const user = interaction.options.getUser("user"); - const members = interaction.guild.members.cache; - const member = members.get(interaction.member.user.id); - const selectedMember = members.get(user.id); - const name = selectedMember.nickname ?? user.username; - const dmChannel = (await user.createDM().catch(() => null)) as DMChannel | null;; - const id = interaction.options.getNumber("id", true); - const warns = await modTable?.get(`${interaction.guild.id}.${user.id}.warns`).then( - warns => warns as any[] ?? [] - ).catch(() => []); - const newWarns = warns.filter(warn => warn.id !== id); - if (newWarns.length === warns.length) - return await interaction.followUp({ embeds: [errorEmbed(`There is no warn with the id of ${id}.`)] }); - - const unwarnEmbed = new EmbedBuilder() - .setTitle(`✅ • Removed warning`) - .setDescription([ - `**Moderator**: <@${member.id}>`, - `**Original Reason**: ${newWarns.find(warn => warn.id === id)?.reason ?? "No reason provided"}` - ].join("\n")) - .setFooter({ text: `User ID: ${user.id}` }) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setColor(genColor(100)); - const embedDM = new EmbedBuilder() - .setTitle(`🤝 • You were unwarned`) - .setDescription([ - `**Moderator**: ${member.user.username}`, - `**Original Reason**: ${newWarns.find(warn => warn.id === id)?.reason ?? "No reason provided"}` - ].join("\n")) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(100)); - - if (!member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) - return await interaction.followUp({ embeds: [errorEmbed("You need the **Moderate Members** permission to execute this command.")] }); - if (selectedMember === member) - return await interaction.followUp({ embeds: [errorEmbed("You can't unwarn yourself.")] }); - if (!selectedMember.manageable) - return await interaction.followUp({ embeds: [errorEmbed(`You can't unwarn ${name}, because they have a higher role position than Nebula.`)] }); - if (member.roles.highest.position < selectedMember.roles.highest.position) - return await interaction.followUp({ embeds: [errorEmbed(`You can't unwarn ${name}, because they have a higher role position than you.`)] }); - - const settingsTable = await getSettingsTable(db); - const logChannel = await settingsTable?.get(`${interaction.guild.id}.logChannel`).then( - (channel: string | null) => channel - ).catch(() => null); - if (logChannel) { - const channel = await interaction.guild.channels.cache.get(logChannel).fetch().then( - (channel: Channel) => { - if (channel.type != ChannelType.GuildText) return null; - return channel as TextChannel; - } - ).catch(() => null); - if (channel) await channel.send({ embeds: [unwarnEmbed] }); - } - - if (dmChannel) await dmChannel.send({ embeds: [embedDM] }); - await modTable?.set(`${interaction.guild.id}.${user.id}.warns`, newWarns); - await interaction.followUp({ embeds: [unwarnEmbed] }); - } -} diff --git a/src/commands/moderation/warn.ts b/src/commands/moderation/warn.ts deleted file mode 100644 index fa3d9cd..0000000 --- a/src/commands/moderation/warn.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, TextChannel, DMChannel, - Channel, ChannelType -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; -import { getModerationTable, getSettingsTable } from "../../utils/database.js"; - -export default class Warn { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("warn") - .setDescription("Warns a user.") - .addUserOption(user => user - .setName("user") - .setDescription("The user that you want to warn.") - .setRequired(true) - ) - .addStringOption(string => string - .setName("reason") - .setDescription("The reason for the warn.") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const modTable = await getModerationTable(db); - - const user = interaction.options.getUser("user"); - const members = interaction.guild.members.cache; - const member = members.get(interaction.member.user.id); - const selectedMember = members.get(user.id); - const name = selectedMember.nickname ?? user.username; - const dmChannel = (await user.createDM().catch(() => null)) as DMChannel | null;; - - const newWarn = { - id: Date.now(), - userId: user.id, - moderator: member.id, - reason: interaction.options.getString("reason") ?? "No reason provided" - }; - - const warnEmbed = new EmbedBuilder() - .setTitle(`✅ • Warned ${user.username}`) - .setDescription([ - `**Moderator**: <@${member.id}>`, - `**Reason**: ${interaction.options.getString("reason") ?? "No reason provided"}` - ].join("\n")) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(100)); - const embedDM = new EmbedBuilder() - .setTitle(`😡 • You were warned`) - .setDescription([ - `**Moderator**: ${member.user.username}`, - `**Reason**: ${interaction.options.getString("reason") ?? "No reason provided"}` - ].join("\n")) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(0)); - - - if (!member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) - return await interaction.followUp({ embeds: [errorEmbed("You need the **Moderate Members** permission to execute this command.")] }); - if (selectedMember === member) - return await interaction.followUp({ embeds: [errorEmbed("You can't warn yourself.")] }); - if (selectedMember.user.bot) - return await interaction.followUp({ embeds: [errorEmbed(`You can't warn ${name}, because it's a bot.`)] }); - if (!selectedMember.manageable) - return await interaction.followUp({ embeds: [errorEmbed(`You can't warn ${name}, because they have a higher role position than Nebula.`)] }); - if (member.roles.highest.position < selectedMember.roles.highest.position) - return await interaction.followUp({ embeds: [errorEmbed(`You can't warn ${name}, because they have a higher role position than you.`)] }); - - const settingsTable = await getSettingsTable(db); - const logChannel = await settingsTable?.get(`${interaction.guild.id}.logChannel`).then( - (channel: string | null) => channel - ).catch(() => null); - if (logChannel) { - const channel = await interaction.guild.channels.cache.get(logChannel).fetch().then( - (channel: Channel) => { - if (channel.type != ChannelType.GuildText) return null; - return channel as TextChannel; - } - ).catch(() => null); - if (channel) await channel.send({ embeds: [warnEmbed] }); - } - - if (dmChannel) await dmChannel.send({ embeds: [embedDM] }); - await modTable?.push(`${interaction.guild.id}.${user.id}.warns`, newWarn); - await interaction.followUp({ embeds: [warnEmbed] }); - } -} diff --git a/src/commands/moderation/warns.ts b/src/commands/moderation/warns.ts deleted file mode 100644 index 2a42fc5..0000000 --- a/src/commands/moderation/warns.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; -import { getModerationTable } from "../../utils/database.js"; - -type Warn = { - id: number; - moderator: string; - reason: string; -} - -export default class Warns { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("warns") - .setDescription("Warns of a user.") - .addUserOption(user => user - .setName("user") - .setDescription("The user that you want to see.") - .setRequired(true) - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const modTable = await getModerationTable(db); - - const user = interaction.options.getUser("user"); - const members = interaction.guild.members.cache; - const member = members.get(interaction.member.user.id); - - const warns = await modTable.get(`${interaction.guild.id}.${user.id}.warns`).then( - warns => { - if (!warns) return [] as Warn[]; - return warns as Warn[] ?? [] as Warn[]; - } - ).catch(() => [] as Warn[]); - - const warnsEmbed = new EmbedBuilder() - .setTitle(`✅ • Warns of ${user.username}`) - .setFields( - warns.length > 0 ? warns.map(warn => { - return { - name: `#${warn.id}`, - value: [ - `**Moderator**: <@${warn.moderator}>`, - `**Reason**: ${warn.reason}`, - `**Date**: `, - ].join("\n"), - inline: true, - }; - }) : [{ - name: "No warns", - value: "This user has no warns." - }] - ) - .setThumbnail(user.displayAvatarURL()) - .setAuthor({ name: `• ${user.username}`, iconURL: user.displayAvatarURL() }) - .setFooter({ text: `User ID: ${user.id}` }) - .setColor(genColor(100)); - - if (!member.permissions.has(PermissionsBitField.Flags.ModerateMembers)) - return await interaction.followUp({ embeds: [errorEmbed("You need the **Moderate Members** permission to execute this command.")] }); - - await interaction.followUp({ embeds: [warnsEmbed] }); - } -} diff --git a/src/commands/nebula/about.ts b/src/commands/nebula/about.ts deleted file mode 100644 index a43f228..0000000 --- a/src/commands/nebula/about.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { SlashCommandSubcommandBuilder, EmbedBuilder, type ChatInputCommandInteraction } from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import randomise from "../../utils/randomise.js"; - -export default class About { - data: SlashCommandSubcommandBuilder; - constructor() { - this.data = new SlashCommandSubcommandBuilder() - .setName("about") - .setDescription("Shows information about the bot."); - } - - async run(interaction: ChatInputCommandInteraction) { - const client = interaction.client; - const hearts = ["💖", "💝", "💓", "💗", "💘", "💟", "💕", "💞"]; - - const aboutEmbed = new EmbedBuilder() - .setAuthor({ name: "• About", iconURL: client.user.displayAvatarURL() }) - .setDescription("Nebula is a multiplatform, multipurpose bot with the ability to add extensions to have additional features.") - .setFields( - { - name: "📃 • General", - value: ["**Version**: v0.1-alpha", `**Guild count**: ${client.guilds.cache.size}`].join("\n"), - inline: true - }, - { - name: "🌌 • Entities involved", - value: [ - "**Head developer**: Goos", - "**Developers**: Golem64, Pigpot, ThatBOI", - "**Designers**: ArtyH, Optix, proJM, Slider_on_the_black", - "**Translators**: Dimkauzh, Golem64, Optix, SaFire, ThatBOI", - "And **YOU**, for using Nebula." - ].join("\n") - } - ) - .setFooter({ text: `Made by the Nebula team with ${randomise(hearts)}` }) - .setThumbnail(client.user.displayAvatarURL()) - .setColor(genColor(270)); - - await interaction.followUp({ embeds: [aboutEmbed] }); - } -} diff --git a/src/commands/nebula/apply.ts b/src/commands/nebula/apply.ts deleted file mode 100644 index 6a72777..0000000 --- a/src/commands/nebula/apply.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { SlashCommandSubcommandBuilder, EmbedBuilder, type ChatInputCommandInteraction } from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import randomise from "../../utils/randomise.js"; - -export default class About { - data: SlashCommandSubcommandBuilder; - constructor() { - this.data = new SlashCommandSubcommandBuilder() - .setName("apply") - .setDescription("Apply as a team member in Nebula!"); - } - - async run(interaction: ChatInputCommandInteraction) { - const client = interaction.client; - const hearts = ["💖", "💝", "💓", "💗", "💘", "💟", "💕", "💞", "❣️", "❤️‍🔥"]; - - const applyEmbed = new EmbedBuilder() - .setAuthor({ name: `• Apply for team!`, iconURL: client.user.displayAvatarURL() }) - .setDescription("We're currently looking for people that could extend our team to help us make Nebula faster and better!") - .setFields( - { - name: "👥 • Who we're looking for", - value: [ - "- **Developers**: Code and implement our back-end in [TypeScript](https://www.typescriptlang.org/).", - "- **Front-End Devs**: Make great experiences for our users by making the designs interactive using [Svelte](https://svelte.dev/).", - "- **UI/UX/Icon designers**: Help us make Nebula not only work great, but also look great." - ].join("\n") - }, - { - name: "❓ • How to apply", - value: [ - "Join our [team applicants server](https://discord.gg/5hkHwGCbju) to find information on applying, resources + getting interviewed at.", - `Thank you for your interest at joining Nebula, we appreciate it ${randomise(hearts)}` - ].join("\n\n") - } - ) - .setFooter({ text: `Made by the Nebula team with ${randomise(hearts)}` }) - .setThumbnail(client.user.displayAvatarURL()) - .setColor(genColor(270)); - - await interaction.followUp({ embeds: [applyEmbed] }); - } -} diff --git a/src/commands/nebula/donate.ts b/src/commands/nebula/donate.ts deleted file mode 100644 index b86025c..0000000 --- a/src/commands/nebula/donate.ts +++ /dev/null @@ -1,33 +0,0 @@ -import { SlashCommandSubcommandBuilder, EmbedBuilder, type ChatInputCommandInteraction } from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; - -export default class Donate { - data: SlashCommandSubcommandBuilder; - constructor() { - this.data = new SlashCommandSubcommandBuilder() - .setName("donate") - .setDescription("Helps us keep up Nebula!"); - } - - async run(interaction: ChatInputCommandInteraction) { - const donationEmbed = new EmbedBuilder() - .setTitle("Donate") - .setDescription([ - "Thanks for being interested into donating for Nebula.", - "We'll be grateful for any donation and use it towards paying our server costs and software costs.", - "[Click here](https://nebula.fyreblitz.com/) to donate via PayPal!" - ].join("\n")) - .setFields({ - name: "🤔 • Why should I donate?", - value: [ - "By donating, you help the Nebula team to continue existing and keep production going as well as keeping our servers alive - Nebula is completely free and, as of now, doesn't have any other way to have upkeep or funding, and donating is a great way to help us.", - "If you donated, you can reach out to us to request a special donators role to flex to your friends.", - "Additionally, you're helping us keep up the servers and make more amazing features :)." - ].join("\n\n") - }) - .setThumbnail(interaction.client.user.avatarURL()) - .setColor(genColor(270)); - - await interaction.followUp({ embeds: [donationEmbed] }); - } -} diff --git a/src/commands/nebula/news.ts b/src/commands/nebula/news.ts deleted file mode 100644 index a5f3fd8..0000000 --- a/src/commands/nebula/news.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, ActionRowBuilder, - ButtonBuilder, ButtonStyle, type ChatInputCommandInteraction -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import { getNewsTable } from "../../utils/database.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; - -export default class News { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("news") - .setDescription("The news of Nebula.") - .addNumberOption(option => option - .setName("page") - .setDescription("The page of the news you want to see") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const newsletterTable = await getNewsTable(db); - - const nebulaId = "903852579837059113" - let page = interaction.options.getNumber("page") ?? 1; - - const news = await newsletterTable.get(`${nebulaId}.news`).then( - (news: any) => news as any[] ?? [] - ).catch(() => []); - const newsSorted = (Object.values(news) as any[])?.sort((a, b) => b.createdAt - a.createdAt); - - if (newsSorted.length == 0) { - return await interaction.followUp({ - embeds: [errorEmbed("No news found.\nAdmins can add news with the **/settings news add** command.")] - }); - } - - if (page > newsSorted.length) page = newsSorted.length; - if (page < 1) page = 1; - - let currentNews = newsSorted[page - 1]; - let newsEmbed = new EmbedBuilder() - .setAuthor({ name: currentNews.author, iconURL: currentNews.authorPfp ?? null }) - .setTitle(currentNews.title) - .setDescription(currentNews.body) - .setImage(currentNews.imageURL || null) - .setTimestamp(parseInt(currentNews.updatedAt)) - .setFooter({ text: `Page ${page} of ${newsSorted.length} • ID: ${currentNews.id}` }) - .setColor(genColor(270)); - - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("left") - .setEmoji("1137330341472915526") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId("right") - .setEmoji("1137330125004869702") - .setStyle(ButtonStyle.Primary) - ); - - await interaction.followUp({ embeds: [newsEmbed], components: [row] }); - - const buttonCollector = interaction.channel.createMessageComponentCollector({ - filter: i => i.user.id === interaction.user.id, - time: 60000 - }); - - buttonCollector.on("collect", async i => { - if (!i.isButton()) return; - const id = i.customId; - - if (id == "left") { - page--; - if (page < 1) page = newsSorted.length; - } else if (id == "right") { - page++; - if (page > newsSorted.length) page = 1; - } - - currentNews = newsSorted[page - 1]; - newsEmbed = new EmbedBuilder() - .setAuthor({ name: currentNews.author, iconURL: currentNews.authorPfp ?? null }) - .setTitle(currentNews.title) - .setDescription(currentNews.body) - .setImage(currentNews.imageURL || null) - .setTimestamp(parseInt(currentNews.updatedAt)) - .setFooter({ text: `Page ${page} of ${newsSorted.length} • ID: ${currentNews.id}` }) - .setColor(genColor(270)); - - await interaction.editReply({ embeds: [newsEmbed], components: [row] }); - await i.deferUpdate(); - }); - } -} diff --git a/src/commands/nebula/serverboard.ts b/src/commands/nebula/serverboard.ts deleted file mode 100644 index 60f8d42..0000000 --- a/src/commands/nebula/serverboard.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { - SlashCommandSubcommandBuilder, ButtonBuilder, ActionRowBuilder, - ButtonStyle, type ChatInputCommandInteraction, ButtonInteraction, - CacheType, StringSelectMenuInteraction, UserSelectMenuInteraction, - RoleSelectMenuInteraction, MentionableSelectMenuInteraction, ChannelSelectMenuInteraction -} from "discord.js"; -import quickSort from "../../utils/quickSort.js"; -import serverEmbed from "../../utils/embeds/serverEmbed.js"; -import database, { getNewsTable, getServerboardTable } from "../../utils/database.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; - -export default class Serverboard { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("serverboard") - .setDescription("Shows the servers that have Nebula.") - .addNumberOption(option => option - .setName("page") - .setDescription("The page you want to see.") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const serverbTable = await getServerboardTable(db); - - // Receiving necessary data - const guilds = interaction.client.guilds.cache; - const guildsMapped = {}; - - // Simplifying the data - const shownGuilds = (await serverbTable.all().catch(() => [])) as any[]; - for (const guild of guilds.values()) { - const shownVal = shownGuilds?.find(shown => shown?.id == guild.id)?.value?.shown; - const isShown = shownVal == null ? null : Boolean(shownVal); - const isCommunity = guild?.rulesChannelId != null; - - if (isShown == false) continue; - if (isShown == null && !isCommunity) continue; - - const members = guild.memberCount + ":" + guild.id; - guildsMapped[members] = guild; - } - - if (Object.keys(guildsMapped).length == 0) - return await interaction.followUp({ - embeds: [errorEmbed("There are no servers with Nebula in them that are shown.")] - }); - - // Sorting the data - const sortedGuilds = quickSort( - [...Object.keys(guildsMapped).map(i => Number(i.split(":")[0]))], - [[...Object.values(guildsMapped)]], - 0, - Object.keys(guildsMapped).length - 1 - ); - - // Additional data - const guildsSorted = sortedGuilds[1][0].reverse(); - const pages = guildsSorted.length; - const argPage = interaction.options.getNumber("page", false); - let page = (argPage - 1 <= 0 ? 0 : argPage - 1 > pages ? pages - 1 : argPage - 1) || 0; - - // Creating the embed - const guild = guildsSorted[page]; - const subscriptionsTable = await getNewsTable(db); - const subs = await subscriptionsTable?.get(`${guild.id}.subscriptions`).then( - subs => subs?.length > 0 ? subs as string[] : [] as string[] - ).catch(() => [] as string[]); - - let embed = await serverEmbed({ - guild, - page: page + 1, - pages, - showInvite: true, - showSubs: subs.length > 0, - subs: subs.length - }); - - // Sending the embed - const row = new ActionRowBuilder().addComponents( - new ButtonBuilder() - .setCustomId("left") - .setEmoji("1137330341472915526") - .setStyle(ButtonStyle.Primary), - new ButtonBuilder() - .setCustomId("right") - .setEmoji("1137330125004869702") - .setStyle(ButtonStyle.Primary) - ); - - await interaction.followUp({ embeds: [embed], components: [row] }); - - // Listening for button events - const collectorFilter = i => i.user.id === interaction.user.id; - const collector = interaction.channel.createMessageComponentCollector({ filter: collectorFilter, time: 60000 }); - collector.on("collect", async interaction => { - page = await handlePageUpdate(interaction, page, pages, row, guildsSorted); - }); - } -} - -async function handlePageUpdate( - interaction: StringSelectMenuInteraction | - UserSelectMenuInteraction | - RoleSelectMenuInteraction | - MentionableSelectMenuInteraction | - ChannelSelectMenuInteraction | - ButtonInteraction, page: number, pages: number, row, guildsSorted: any[] -) { - const db = await database(); - - let embed; - let guild; - let subs; - - const subscriptionsTable = db.table("subscriptions"); - const subscriptions = await subscriptionsTable.all(); - - switch (interaction.customId) { - case "left": - page--; - if (page < 0) page = pages - 1; - guild = guildsSorted[page]; - subs = subscriptions.filter(sub => (sub.value as string[] ?? [] as string[]).includes(guild.id)); - - embed = await serverEmbed({ guild, page: page + 1, pages, showInvite: true, showSubs: subs.length > 0, subs: subs.length }); - break; - case "right": - page++; - if (page >= pages) page = 0; - guild = guildsSorted[page]; - subs = subscriptions.filter(sub => (sub.value as string[]).includes(guild.id)); - - embed = await serverEmbed({ guild, page: page + 1, pages, showInvite: true, showSubs: subs.length > 0, subs: subs.length }); - break; - } - - interaction.message.edit({ embeds: [embed], components: [row] }); - interaction.deferUpdate(); - return page; -} diff --git a/src/commands/nebula/subscribe.ts b/src/commands/nebula/subscribe.ts deleted file mode 100644 index d04dfe9..0000000 --- a/src/commands/nebula/subscribe.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, type ChatInputCommandInteraction, DMChannel -} from "discord.js"; -import { genColor } from "../../utils/colorGen.js"; -import { getNewsTable } from "../../utils/database.js"; -import errorEmbed from "../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; - -type SubscriptionType = string[]; - -export default class Subscribe { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("subscribe") - .setDescription("Subscribe to the news of Nebula."); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const newsTable = await getNewsTable(db);; - - const user = interaction.user; - - const nebulaId = "903852579837059113"; - const subscriptions = await newsTable?.get(`${nebulaId}.subscriptions`).then( - (subscriptions: any) => subscriptions as SubscriptionType ?? [] as SubscriptionType - ).catch(() => [] as SubscriptionType); - const hasSub = subscriptions?.includes(user.id); - - const dmChannel = (await interaction.user.createDM().catch(() => null)) as DMChannel | null; - if (!dmChannel) return await interaction.followUp({ - embeds: [errorEmbed("You need to **enable DMs from server members** to subscribe to the news.")] - }); - const sendDms = await dmChannel?.send("You have updated the subscription status of \`Nebula\`.").catch(() => null); - if (!sendDms) { - await newsTable.pull(`${nebulaId}.subscriptions`, user.id); - return await interaction.followUp({ - embeds: [errorEmbed("You need to **enable DMs from server members** to subscribe to the news.")] - }); - } - - await newsTable[!hasSub ? "push" : "pull"](`${nebulaId}.subscriptions`, user.id); - - const subscriptionEmbed = new EmbedBuilder() - .setTitle(`✅ • ${hasSub ? "Unsubscribed" : "Subscribed"} ${hasSub ? "from" : "to"} Nebula`) - .setDescription(`You have ${hasSub ? "un" : ""}subscribed to the news of Nebula.`) - .setColor(genColor(100)); - - await interaction.followUp({ embeds: [subscriptionEmbed] }); - } -} diff --git a/src/commands/news/Add.ts b/src/commands/news/Add.ts new file mode 100644 index 0000000..4420702 --- /dev/null +++ b/src/commands/news/Add.ts @@ -0,0 +1,82 @@ +import { + ActionRowBuilder, + EmbedBuilder, + ModalBuilder, + PermissionsBitField, + SlashCommandSubcommandBuilder, + TextInputBuilder, + TextInputStyle, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../../utils/colorGen"; +import { addNews, listAllQuery } from "../../utils/database/news"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { sendChannelNews } from "../../utils/sendChannelNews"; + +export default class Add { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder().setName("add").setDescription("Add your news."); + } + + async run(interaction: ChatInputCommandInteraction) { + const guild = interaction.guild!; + if ( + !guild.members.cache + .get(interaction.user.id) + ?.permissions.has(PermissionsBitField.Flags.ManageGuild) + ) + return await errorEmbed( + interaction, + "You can't execute this command.", + "You need the **Manage Server** permission." + ); + + const firstActionRow = new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("title") + .setPlaceholder("Write a title") + .setMaxLength(100) + .setStyle(TextInputStyle.Short) + .setLabel("Title") + .setRequired(true) + ); + + const secondActionRow = new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("body") + .setPlaceholder("Insert your content here") + .setMaxLength(4000) + .setStyle(TextInputStyle.Paragraph) + .setLabel("Content (supports Markdown)") + .setRequired(true) + ); + + const newsModal = new ModalBuilder() + .setCustomId("addnews") + .setTitle("Write your news.") + .addComponents(firstActionRow, secondActionRow); + + await interaction.showModal(newsModal).catch(err => console.error(err)); + interaction.client.once("interactionCreate", async i => { + if (!i.isModalSubmit()) return; + + const id = (listAllQuery.all(guild.id).length + 1).toString(); + addNews( + guild.id, + i.fields.getTextInputValue("title"), + i.fields.getTextInputValue("body"), + i.user.displayName, + i.user.avatarURL()!, + null!, + id + ); + + await sendChannelNews(guild, id, interaction).catch(err => console.error(err)); + await i.reply({ + embeds: [new EmbedBuilder().setTitle("News added.").setColor(genColor(100))], + ephemeral: true + }); + }); + } +} diff --git a/src/commands/news/Edit.ts b/src/commands/news/Edit.ts new file mode 100644 index 0000000..48f3a0f --- /dev/null +++ b/src/commands/news/Edit.ts @@ -0,0 +1,112 @@ +import { + ActionRowBuilder, + EmbedBuilder, + ModalBuilder, + PermissionsBitField, + SlashCommandSubcommandBuilder, + TextInputBuilder, + TextInputStyle, + type ChatInputCommandInteraction, + type Role, + type TextChannel +} from "discord.js"; +import { genColor } from "../../utils/colorGen"; +import { get, updateNews } from "../../utils/database/news"; +import { getSetting } from "../../utils/database/settings"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { sendChannelNews } from "../../utils/sendChannelNews"; + +export default class Edit { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("edit") + .setDescription("Edits the news of your guild.") + .addStringOption(string => + string + .setName("id") + .setDescription("The ID of the news you want to edit.") + .setRequired(true) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const guild = interaction.guild!; + if ( + !guild.members.cache + .get(interaction.user.id) + ?.permissions.has(PermissionsBitField.Flags.ManageGuild) + ) + return await errorEmbed( + interaction, + "You can't execute this command.", + "You need the **Manage Server** permission." + ); + + const id = interaction.options.getString("id")!; + const news = get(id); + if (!news) return await errorEmbed(interaction, "The specified news don't exist."); + + const firstActionRow = new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("title") + .setMaxLength(100) + .setStyle(TextInputStyle.Short) + .setLabel("Title") + .setValue(news.title) + .setRequired(true) + ); + + const secondActionRow = new ActionRowBuilder().addComponents( + new TextInputBuilder() + .setCustomId("body") + .setMaxLength(4000) + .setStyle(TextInputStyle.Paragraph) + .setLabel("Content (supports Markdown)") + .setValue(news.body) + .setRequired(true) + ); + + const editModal = new ModalBuilder() + .setCustomId("editnews") + .setTitle(`Edit news: ${news.title}`) + .addComponents(firstActionRow, secondActionRow); + + await interaction.showModal(editModal).catch(err => console.error(err)); + interaction.client.once("interactionCreate", async i => { + if (!i.isModalSubmit()) return; + + const role = getSetting(guild.id, "news", "role_id") as string; + let roleToSend: Role | undefined; + if (role) roleToSend = guild.roles.cache.get(role); + const title = i.fields.getTextInputValue("title"); + const body = i.fields.getTextInputValue("body"); + + if (!getSetting(guild.id, "news", "edit_original_message")) + await sendChannelNews(guild, id, interaction, title, body); + + const embed = new EmbedBuilder() + .setAuthor({ name: `• ${news.author}`, iconURL: news.authorPFP }) + .setTitle(title) + .setDescription(body) + .setTimestamp(parseInt(news.updatedAt.toString()) ?? null) + .setFooter({ text: `Edited news from ${guild.name}\nID: ${news.id}` }) + .setColor(genColor(200)); + + ( + guild.channels.cache.get( + (getSetting(guild.id, "news", "channel_id") as string) ?? interaction.channel?.id + ) as TextChannel + )?.messages.edit(news.messageID, { + embeds: [embed], + content: roleToSend ? `<@&${roleToSend.id}>` : undefined + }); + + updateNews(id, title, body); + await interaction.reply({ + embeds: [new EmbedBuilder().setTitle("News edited.").setColor(genColor(100))], + ephemeral: true + }); + }); + } +} diff --git a/src/commands/news/Remove.ts b/src/commands/news/Remove.ts new file mode 100644 index 0000000..0cb5bee --- /dev/null +++ b/src/commands/news/Remove.ts @@ -0,0 +1,55 @@ +import { + EmbedBuilder, + PermissionsBitField, + SlashCommandSubcommandBuilder, + TextChannel, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../../utils/colorGen"; +import { deleteNews, get } from "../../utils/database/news"; +import { getSetting } from "../../utils/database/settings"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; + +export default class Remove { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("remove") + .setDescription("Removes news from your guild.") + .addStringOption(string => + string + .setName("id") + .setDescription("The ID of the news. (found in the footer)") + .setRequired(true) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const guild = interaction.guild!; + if ( + !guild.members.cache + .get(interaction.user.id) + ?.permissions.has(PermissionsBitField.Flags.ManageGuild) + ) + return await errorEmbed( + interaction, + "You can't execute this command.", + "You need the **Manage Server** permission." + ); + + const id = interaction.options.getString("id")!; + const news = get(id); + if (!news) return await errorEmbed(interaction, "The specified news don't exist."); + + const newsChannel = (await guild.channels + .fetch((getSetting(guild.id, "news", "channel_id") as string) ?? interaction.channel?.id) + .catch(() => null)) as TextChannel; + + if (newsChannel) await newsChannel.messages.delete(news.messageID); + deleteNews(id); + await interaction.reply({ + embeds: [new EmbedBuilder().setTitle("News removed.").setColor(genColor(100))], + ephemeral: true + }); + } +} diff --git a/src/commands/news/View.ts b/src/commands/news/View.ts new file mode 100644 index 0000000..bfc2da4 --- /dev/null +++ b/src/commands/news/View.ts @@ -0,0 +1,92 @@ +import { + ActionRowBuilder, + ButtonBuilder, + ButtonInteraction, + ButtonStyle, + EmbedBuilder, + SlashCommandSubcommandBuilder, + type ChatInputCommandInteraction +} from "discord.js"; +import { genColor } from "../../utils/colorGen"; +import { listAllNews } from "../../utils/database/news"; +import { errorEmbed } from "../../utils/embeds/errorEmbed"; + +export default class View { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("view") + .setDescription("View the news of this server.") + .addNumberOption(number => + number.setName("page").setDescription("The page of the news that you want to see.") + ); + } + + async run(interaction: ChatInputCommandInteraction) { + let page = interaction.options.getNumber("page") ?? 1; + const news = listAllNews(interaction.guild?.id!); + const sortedNews = (Object.values(news) as any[])?.sort((a, b) => b.createdAt - a.createdAt); + + if (!news || !sortedNews || !sortedNews.length) + return await errorEmbed( + interaction, + "No news found.", + "Admins can add news with the **/news add** command." + ); + + if (page > sortedNews.length) page = sortedNews.length; + if (page < 1) page = 1; + + function getEmbed() { + const currentNews = sortedNews[page - 1]; + return new EmbedBuilder() + .setAuthor({ name: `• ${currentNews.author}`, iconURL: currentNews.authorPFP }) + .setTitle(currentNews.title) + .setDescription(currentNews.body) + .setImage(currentNews.imageURL || null) + .setTimestamp(parseInt(currentNews.updatedAt)) + .setFooter({ text: `Page ${page} of ${sortedNews.length} • ID: ${currentNews.id}` }) + .setColor(genColor(200)); + } + + const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setCustomId("left") + .setEmoji("1298708251256291379") + .setStyle(ButtonStyle.Primary), + new ButtonBuilder() + .setCustomId("right") + .setEmoji("1298708281493160029") + .setStyle(ButtonStyle.Primary) + ); + + const reply = await interaction.reply({ embeds: [getEmbed()], components: [row] }); + const collector = reply.createMessageComponentCollector({ time: 30000 }); + collector.on("collect", async (i: ButtonInteraction) => { + if (i.message.id != (await reply.fetch()).id) + return await errorEmbed( + i, + "For some reason, this click would've caused the bot to error. Thankfully, this message right here prevents that." + ); + + if (i.user.id != interaction.user.id) + return await errorEmbed(i, "You aren't the person who executed this command."); + + collector.resetTimer({ time: 30000 }); + switch (i.customId) { + case "left": + page--; + if (page < 1) page = sortedNews.length; + await i.update({ embeds: [getEmbed()], components: [row] }); + break; + case "right": + page++; + if (page > sortedNews.length) page = 1; + await i.update({ embeds: [getEmbed()], components: [row] }); + break; + } + }); + + collector.on("end", async () => await interaction.editReply({ components: [] })); + } +} diff --git a/src/commands/settings/command/list.ts b/src/commands/settings/command/list.ts deleted file mode 100644 index b63d47e..0000000 --- a/src/commands/settings/command/list.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - SlashCommandSubcommandBuilder, - EmbedBuilder, - PermissionsBitField, - type ChatInputCommandInteraction, -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; -import { getSettingsTable } from "../../../utils/database.js"; - -export default class List { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("list") - .setDescription("Lists all disabled commands."); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const settingsTable = await getSettingsTable(db); - - const member = interaction.guild.members.cache.get(interaction.user.id); - - if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) - return await interaction.followUp({ - embeds: [errorEmbed("You need **Manage Server** permissions to list commands.")], - }); - - const disabledCommands = await settingsTable - ?.get(`${interaction.guild.id}.disabledCommands`) - .then((disabledCommands) => disabledCommands as any[] ?? []) - .catch(() => []); - - const listEmbed = new EmbedBuilder() - .setTitle("📃 • Disabled commands") - .setDescription( - !disabledCommands || disabledCommands?.length == 0 - ? "There are no disabled commands." - : disabledCommands - .map((command) => { - const [commandName, subcommandName] = command.split("/"); - return `/${commandName}${subcommandName ? ` ${subcommandName}` : ""}`; - }) - .join("\n") - ) - .setColor(genColor(100)); - - return await interaction.followUp({ embeds: [listEmbed] }); - } -} diff --git a/src/commands/settings/command/toggle.ts b/src/commands/settings/command/toggle.ts deleted file mode 100644 index 0046127..0000000 --- a/src/commands/settings/command/toggle.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import Commands from "../../../handlers/commands.js"; -import { getSettingsTable } from "../../../utils/database.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; - -export default class Toggle { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("toggle") - .setDescription("Enables/disables a command.") - .addStringOption(option => option - .setName("command") - .setDescription("Layout: \"/topcommand (subcommand) (group)\".") - .setRequired(true) - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const settingsTable = await getSettingsTable(db); - - const commands = new Commands(interaction.client); - await commands.loadCommands(); - - const commandPath = interaction.options.getString("command", true); - let [commandName, subcommandName, subcommandGroupName] = commandPath.split(" "); - commandName = commandName.replace("/", ""); - const disabledCommands = await settingsTable?.get(`${interaction.guild.id}.disabledCommands`).then( - (disabledCommands) => disabledCommands as any[] ?? [] - ).catch(() => []); - - const hasCommand = (name: string, subcommand?: string, subcommandGroup?: string) => { - return commands.commands.some(command => - command.name === name && - (!subcommand || command.options.some(opt => opt.name === subcommand)) && - (!subcommandGroup || command.options.some( - opt => opt.type === "SUB_COMMAND_GROUP" && opt.options?.some(opt => opt.name === subcommandGroup) - )) - ); - }; - - const member = interaction.guild.members.cache.get(interaction.user.id); - const isEnabled = !disabledCommands.includes(commandName); - const updatedDisabledCommands = !isEnabled - ? disabledCommands.filter((cmd) => cmd !== commandName) - : [...disabledCommands, commandName]; - - if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) - return await interaction.followUp({ - embeds: [errorEmbed("You need the **Manage Server** permission to enable commands.")], - }); - - if (!hasCommand(commandName, subcommandName, subcommandGroupName)) { - return await interaction.followUp({ embeds: [errorEmbed("The specified command doesn't exist.")] }); - } - - const embed = new EmbedBuilder() - .setTitle(`⌚ • ${isEnabled ? "Disabling" : "Enabling"} ${commandPath}.`) - .setDescription("The command hasn't been updated yet, we will edit this message once it has.") - .setColor(genColor(100)); - - interaction.followUp({ embeds: [embed] }); - await settingsTable.set(`${interaction.guild.id}.disabledCommands`, updatedDisabledCommands); - - commands.registerCommandsForGuild(interaction.guild, ...updatedDisabledCommands).then(() => { - embed - .setTitle(`✅ • ${isEnabled ? "Disabled" : "Enabled"} ${commandPath}.`) - .setDescription(`The command has been ${isEnabled ? "disabled." : "enabled."}`); - - interaction.editReply({ embeds: [embed] }); - }); - } -} diff --git a/src/commands/settings/leveling/block-channels.ts b/src/commands/settings/leveling/block-channels.ts deleted file mode 100644 index 64bd32e..0000000 --- a/src/commands/settings/leveling/block-channels.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, - ChannelType, - Collection, - NonThreadGuildBasedChannel -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { getSettingsTable } from "../../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export default class BlockChannels { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("block-channels") - .setDescription("Toggles blocked channels for leveling, no options = view blocked.") - .addChannelOption(option => option - .setName("channel") - .setDescription("A channel to toggle blocked status in.") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const settingsTable = await getSettingsTable(db); - - const guild = interaction.guild; - const channel = interaction.options.getChannel("channel"); - const member = interaction.guild.members.cache.get(interaction.user.id); - - if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await interaction.followUp({ - embeds: [errorEmbed("You need **Manage Server** permissions to set a channel.")] - }); - } - - const blocked = await settingsTable?.get(`${guild.id}.leveling.blockedchannels`).then( - blocked => blocked as any[] ?? [] - ).catch(() => []); - if (!channel) { - return await interaction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle("📃 • Blocked Leveling Channels") - .setDescription( - blocked?.length == 0 ? - "There are no blocked channels." : - `${blocked?.map(id => `- <#${id}>`).join("\n")}` - ) - .setColor(genColor(100)) - ] - }); - } - - if (channel?.type != ChannelType.GuildText) { - return await interaction.followUp({ - embeds: [errorEmbed("You must provide a text channel.")] - }); - } - - const perms = (await interaction.guild.channels.fetch().then( - (channels: Collection) => channels as Collection - )).get(channel.id)?.permissionsFor(guild.members.me); - if (!perms.has(PermissionsBitField.Flags.SendMessages)) { - return await interaction.followUp({ - embeds: [errorEmbed("I don't have permission to send messages in that channel.")] - }); - } - - const isBlocked = blocked?.includes(channel.id); - await settingsTable[isBlocked ? "pull" : "push"](`${interaction.guild.id}.leveling.blockedchannels`, channel.id); - - await interaction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle("✅ • Blocked Leveling Channels") - .setDescription( - isBlocked ? - `Unblocked <#${channel.id}>.` : - `Blocked <#${channel.id}>.` - ) - .setColor(genColor(100)) - ] - }); - } -} diff --git a/src/commands/settings/leveling/channel.ts b/src/commands/settings/leveling/channel.ts deleted file mode 100644 index 32f49cd..0000000 --- a/src/commands/settings/leveling/channel.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, - ChannelType, - Collection, - NonThreadGuildBasedChannel -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { getSettingsTable } from "../../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export default class Channel { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("channel") - .setDescription("Sets the channel for level up messages (no channel = no messages).") - .addChannelOption(option => option - .setName("channel") - .setDescription("The channel where level up messages are sent in.") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const settingsTable = await getSettingsTable(db); - - const guild = interaction.guild; - const user = interaction.user; - const channel = interaction.options.getChannel("channel"); - const member = interaction.guild.members.cache.get(user.id); - - if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await interaction.followUp({ - embeds: [errorEmbed("You need **Manage Server** permissions to set a channel.")] - }); - } - - if (!channel) { - await settingsTable?.delete(`${guild.id}.leveling.channel`).catch(() => ""); - - return await interaction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle("✅ • Leveling Channel") - .setDescription("Level up messages will no longer be sent.") - .setColor(genColor(100)) - ] - }); - } - - if (channel?.type != ChannelType.GuildText) { - return await interaction.followUp({ - embeds: [errorEmbed("You must provide a text channel.")] - }); - } - - const perms = (await guild.channels.fetch().then( - (channels: Collection) => channels as Collection - )).get(channel.id).permissionsFor(guild.members.me); - if (!perms.has(PermissionsBitField.Flags.SendMessages)) { - return await interaction.followUp({ - embeds: [errorEmbed("I don't have permission to send messages in that channel.")] - }); - } - - await settingsTable.set(`${guild.id}.leveling.channel`, channel.id).catch(() => ""); - - await interaction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle("✅ • Leveling Channel") - .setDescription(`Level up messages will now be sent in <#${channel.id}>.`) - .setColor(genColor(100)) - ] - }); - } -} diff --git a/src/commands/settings/leveling/rewards.ts b/src/commands/settings/leveling/rewards.ts deleted file mode 100644 index 4c9e5da..0000000 --- a/src/commands/settings/leveling/rewards.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, - Role, -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { getSettingsTable } from "../../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export type Reward = { - roleId: string, - level: number, -} - -export default class Rewards { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("rewards") - .setDescription("Sets/gets reward roles for each level -> No options = shows.") - .addNumberOption(option => option - .setName("level") - .setDescription("The level to set the reward role for. When set without role option => deletes reward.") - ) - .addRoleOption(option => option - .setName("role") - .setDescription("The role that should be awarded for the level => leaving empty = deletes reward for specified level.") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const settingsTable = await getSettingsTable(db); - - const inputLevel = interaction.options.getNumber("level", false); - const inputRole = interaction.options.getRole("role", false) as Role | null; - - if (!inputLevel && !inputRole) { - // List the rewards - const rewards = await this.getRewards(interaction.guild.id).then(rewards => rewards.sort((a, b) => a.level - b.level)) as Reward[]; - if (rewards.length == 0) { - return await interaction.followUp({ - embeds: [errorEmbed("There are no rewards set for this server.")] - }); - } - - const rewardsEmbed = new EmbedBuilder() - .setTitle("🎁 • Rewards") - .setDescription("Here are the rewards set for this server.") - .setColor(genColor(100)); - - for (const { roleId, level } of rewards) { - if (!roleId) continue; - rewardsEmbed.addFields([{ - name: `Level ${level}`, - value: `<@&${roleId}>`, - }]); - } - - return await interaction.followUp({ - embeds: [rewardsEmbed] - }); - } else if (inputLevel && !inputRole) { - // Delete a reward - const level = Number(inputLevel); - const rewards = await this.getRewards(interaction.guild.id).then(rewards => rewards.sort((a, b) => a?.level - b?.level)) as Reward[]; - if (rewards.length == 0) { - return await interaction.followUp({ - embeds: [errorEmbed("There are no rewards set for this server.")] - }); - } - - const reward = rewards.find(reward => reward?.level == level); - if (!reward) { - return await interaction.followUp({ - embeds: [errorEmbed(`There is no reward set for level ${level}.`)] - }); - } - - const newRewards = rewards.filter(reward => reward.level != level); - await settingsTable?.set(`${interaction.guild.id}.leveling.rewards`, newRewards).catch(() => []); - - return await interaction.followUp({ - embeds: [new EmbedBuilder() - .setTitle("🎁 • Rewards") - .setDescription(`Deleted the reward for level ${level}.`) - .setColor(genColor(100)) - ] - }); - } - - // Set a reward - const level = Number(inputLevel); - const role = await interaction.guild.roles.fetch(String(inputRole?.id)).catch(() => null); - - if (!role) { - return await interaction.followUp({ - embeds: [errorEmbed("That role doesn't exist or couldn't be loaded.")] - }); - } - - const permissions = role?.permissions as PermissionsBitField; - if (level < 0) { - return await interaction.followUp({ - embeds: [errorEmbed("You can't set a reward for a negative level.")] - }); - } - if (level == 0) { - return await interaction.followUp({ - embeds: [errorEmbed("You can't set a reward for level 0.")] - }); - } - if (role.position >= interaction.guild.members.me.roles.highest.position) { - return await interaction.followUp({ - embeds: [errorEmbed("That role is above mine.")] - }); - } - if (role?.name?.includes("everyone")) { - return await interaction.followUp({ - embeds: [errorEmbed("I can't give out the @everyone role.")] - }); - } - if ( - permissions.has(PermissionsBitField.Flags.Administrator) || - permissions.has(PermissionsBitField.Flags.ManageRoles) || - permissions.has(PermissionsBitField.Flags.ManageGuild) || - permissions.has(PermissionsBitField.Flags.ManageChannels) || - permissions.has(PermissionsBitField.Flags.ManageWebhooks) || - permissions.has(PermissionsBitField.Flags.ManageGuildExpressions) || - permissions.has(PermissionsBitField.Flags.ManageMessages) || - permissions.has(PermissionsBitField.Flags.ManageThreads) || - permissions.has(PermissionsBitField.Flags.ManageNicknames) - ) { - return await interaction.followUp({ - embeds: [errorEmbed("I can't give out a role with dangerous permissions.")] - }); - } - - const rewards = await this.getRewards(interaction.guild.id).then(rewards => rewards.sort((a, b) => a?.level - b?.level)) as Reward[]; - const newRewards = rewards.filter(reward => reward.level != level); - newRewards.push({ - roleId: role?.id, - level: level - }); - await settingsTable?.set(`${interaction.guild.id}.leveling.rewards`, newRewards).catch(() => ""); - - return await interaction.followUp({ - embeds: [new EmbedBuilder() - .setTitle("🎁 • Rewards") - .setDescription(`Set the reward for level ${level} to <@&${role?.id}>.`) - .setColor(genColor(100)) - ] - }); - } - - async getRewards(guildId: string): Promise { - const settingsTable = await getSettingsTable(this.db); - const rewards = new Promise((resolve, reject) => { - settingsTable?.get(`${guildId}.leveling.rewards`).then(rewards => { - if (!rewards) return resolve([]); - resolve(rewards); - }).catch(() => { - resolve([]); - }); - }); - return rewards as Promise; - } -} diff --git a/src/commands/settings/leveling/set.ts b/src/commands/settings/leveling/set.ts deleted file mode 100644 index d7e1e93..0000000 --- a/src/commands/settings/leveling/set.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, - type ChatInputCommandInteraction, -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { getLevelingTable, getSettingsTable } from "../../../utils/database.js"; -import { QuickDB } from "quick.db"; -import { Reward } from "./rewards.js"; - -export default class Set { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("set") - .setDescription("Sets the levels for a user.") - .addUserOption(option => option - .setName("user") - .setDescription("The user to set the levels for.") - .setRequired(true) - ) - .addNumberOption(option => option - .setName("levels") - .setDescription("The amount of levels to set the user to.") - .setRequired(true) - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const levelingTable = await getLevelingTable(db); - const settingsTable = await getSettingsTable(db); - - const target = interaction.options.getUser("user", true); - const level = interaction.options.getNumber("levels", true); - - if (level < 0) { - return await interaction.followUp({ - embeds: [errorEmbed("You can't set a user's levels to a negative number.")] - }); - } - - await levelingTable.set(`${interaction.guild.id}.${target.id}`, { - levels: level, - exp: 0 - }).catch(() => ""); - - const levelRewards = await settingsTable?.get(`${interaction.guild.id}.leveling.rewards`).then( - (data) => { - if (!data) return [] as Reward[]; - return data as Reward[] ?? [] as Reward[]; - } - ).catch(() => [] as Reward[]); - const members = await interaction.guild.members.fetch(); - for (const { level: rewardLevel, roleId } of levelRewards) { - const role = interaction.guild.roles.cache.get(roleId); - - if (level >= rewardLevel) { - await members.get(target.id)?.roles.add(role); - continue; - } - - await members.get(target.id)?.roles.remove(role); - } - - await interaction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle("✅ • Levels set!") - .setDescription(`Set ${target}'s levels to ${level}.`) - .setColor(genColor(100)) - ] - }); - } -} diff --git a/src/commands/settings/leveling/toggle.ts b/src/commands/settings/leveling/toggle.ts deleted file mode 100644 index 9e064dd..0000000 --- a/src/commands/settings/leveling/toggle.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, - type ChatInputCommandInteraction, - PermissionsBitField, -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import database from "../../../utils/database.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; - -export default class Toggle { - data: SlashCommandSubcommandBuilder; - constructor() { - this.data = new SlashCommandSubcommandBuilder() - .setName("toggle") - .setDescription("Toggles if leveling is enabled."); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = await database(); - - const enabled = await db.table("settings")?.get(`${interaction.guild.id}.leveling.enabled`).then( - (enabled) => !!enabled - ).catch(() => false); - await db.table("settings").set(`${interaction.guild.id}.leveling.enabled`, !enabled); - - const user = (await interaction.guild.members.me.fetch()) - if (!user.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await interaction.followUp({ - embeds: [errorEmbed("You need **Manage Server** permissions to toggle leveling.")] - }); - } - - await interaction.followUp({ - embeds: [ - new EmbedBuilder() - .setTitle("✅ • Leveling toggled!") - .setDescription(`Leveling is now ${enabled ? "disabled" : "enabled"}.`) - .setColor(genColor(100)) - ] - }); - } -} diff --git a/src/commands/settings/moderation/logs.ts b/src/commands/settings/moderation/logs.ts deleted file mode 100644 index 0dae559..0000000 --- a/src/commands/settings/moderation/logs.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { - SlashCommandSubcommandBuilder, - EmbedBuilder, - PermissionsBitField, - type ChatInputCommandInteraction, - ChannelType, -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; -import { getSettingsTable } from "../../../utils/database.js"; - -export default class Logs { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("logs") - .setDescription("Sets/Remove the logs channel.") - .addChannelOption(option => - option.setName("channel") - .setDescription("Where to send logs in. Empty = deleted.") - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const settingsTable = await getSettingsTable(db); - - const member = interaction.guild.members.cache.get(interaction.user.id); - - if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await interaction.followUp({ - embeds: [errorEmbed("You need **Manage Server** permissions to list commands.")], - }); - } - - const specifiedChannel = interaction.options.getChannel("channel"); - if (specifiedChannel?.type != ChannelType.GuildText) return await interaction.followUp({ embeds: [errorEmbed("You must provide a text channel.")] }); - - if (!specifiedChannel?.id) await settingsTable?.delete(`${interaction.guild.id}.logChannel`); - else await settingsTable?.set(`${interaction.guild.id}.logChannel`, specifiedChannel?.id) - - const listEmbed = new EmbedBuilder() - .setTitle("📃 • Log channel") - .setDescription(`The log channel has been ${specifiedChannel?.id ? `set to <#${specifiedChannel.id}>` : "deleted"}.`) - .setColor(genColor(100)); - - return await interaction.followUp({ embeds: [listEmbed] }); - } -} diff --git a/src/commands/settings/news/add.ts b/src/commands/settings/news/add.ts deleted file mode 100644 index e9072d4..0000000 --- a/src/commands/settings/news/add.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction, ModalBuilder, TextInputBuilder, - ActionRowBuilder, TextInputStyle -} from "discord.js"; -import { getNewsTable } from "../../../utils/database.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import sendSubscribedNews, { News } from "../../../utils/sendSubscribedNews.js"; -import sendChannelNews from "../../../utils/sendChannelNews.js"; -import validateURL from "../../../utils/validateURL.js"; -import { QuickDB } from "quick.db"; - -export default class Add { - data: SlashCommandSubcommandBuilder; - deferred: boolean = false; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("add") - .setDescription("Adds news to your guild."); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const newsTable = await getNewsTable(db); - - const user = interaction.user; - const guild = interaction.guild; - - const author = user.displayName ?? user.username; - const timestamp = Date.now().toString(); - const member = guild.members.cache.get(user.id); - - if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await interaction.reply({ - embeds: [errorEmbed("You need **Manage Server** permissions to add news.")], - }); - } - - const newsModal = new ModalBuilder() - .setCustomId("addnews") - .setTitle("Create new News for your server/project"); - - const titleInput = new TextInputBuilder() - .setCustomId("title") - .setPlaceholder("Title") - .setStyle(TextInputStyle.Short) - .setMaxLength(100) - .setLabel("Title") - .setRequired(true); - - const bodyInput = new TextInputBuilder() - .setCustomId("body") - .setPlaceholder("Content (markdown)") - .setMaxLength(4000) - .setStyle(TextInputStyle.Paragraph) - .setLabel("Content (markdown)") - .setRequired(true); - - const imageURLInput = new TextInputBuilder() - .setCustomId("imageurl") - .setPlaceholder("Big image URL (bottom)") - .setStyle(TextInputStyle.Short) - .setMaxLength(1000) - .setLabel("Big image URL (bottom)") - .setRequired(false); - - const firstActionRow = new ActionRowBuilder().addComponents(titleInput) as ActionRowBuilder; - const secondActionRow = new ActionRowBuilder().addComponents(bodyInput) as ActionRowBuilder; - const thirdActionRow = new ActionRowBuilder().addComponents(imageURLInput) as ActionRowBuilder; - - newsModal.addComponents(firstActionRow, secondActionRow, thirdActionRow); - await interaction.showModal(newsModal).catch((err) => { - console.error(err); - }); - - interaction.client.once("interactionCreate", async (interaction) => { - if (!interaction.isModalSubmit()) return; - if (interaction.customId !== "addnews") return; - - const title = interaction.fields.getTextInputValue("title") as string; - const body = interaction.fields.getTextInputValue("body") as string; - const imageURL = interaction.fields.getTextInputValue("imageurl") as string | undefined; - let validURL = false; - if (imageURL) validURL = validateURL(imageURL); - - if (!validURL && imageURL) { - await interaction.reply({ - embeds: [errorEmbed("The image URL you provided is invalid.")], - }); - return; - } - - const id = crypto.randomUUID(); - const news = { - id, - title, - body, - imageURL, - author, - authorPfp: interaction.user.avatarURL(), - createdAt: timestamp, - updatedAt: timestamp, - messageId: null - }; - - sendSubscribedNews(interaction.guild, news as News).catch((err) => { - console.error(err); - }); - sendChannelNews(interaction.guild, news as News, id).catch((err) => { - console.error(err); - }); - - const embed = new EmbedBuilder() - .setTitle("✅ • News sent!") - .setColor(genColor(100)); - - await newsTable.set(`${guild.id}.news.${id}`, news); - await interaction.reply({ embeds: [embed] }); - }); - } -} diff --git a/src/commands/settings/news/channel.ts b/src/commands/settings/news/channel.ts deleted file mode 100644 index 5803a7b..0000000 --- a/src/commands/settings/news/channel.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { - SlashCommandSubcommandBuilder, - EmbedBuilder, - PermissionsBitField, - type ChatInputCommandInteraction, - ChannelType, -} from "discord.js"; -import { getNewsTable } from "../../../utils/database.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; - -export default class Channel { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("channel") - .setDescription("Sets/removes (when no options) a news channel, all news you post will be sent.") - .addChannelOption((option) => option.setName("channel").setDescription("The channel to send news to.")) - .addRoleOption((option) => option.setName("role").setDescription("The role to ping when news are sent.")); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const newsTable = await getNewsTable(db); - - const guild = interaction.guild; - const channelOption = interaction.options.getChannel("channel"); - const roleOption = interaction.options.getRole("role"); - - const channel = guild.channels.cache.get(channelOption?.id); - const role = guild.roles.cache.get(roleOption?.id ?? ""); - - if (role?.name === "@everyone") { - return await interaction.followUp({ embeds: [errorEmbed("You can't ping @everyone.")] }); - } - if (!channelOption && !roleOption) { - await newsTable.delete(`${guild.id}.channel`); - return await interaction.followUp({ - embeds: [ - new EmbedBuilder().setTitle("✅ • Removed news channel.") - ] - }); - } - - if (!interaction.memberPermissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await interaction.followUp({ - embeds: [errorEmbed("You need **Manage Server** permissions to add news.")], - }); - } - if (channel.type !== ChannelType.GuildText && channel.type !== ChannelType.GuildAnnouncement) { - return await interaction.followUp({ embeds: [errorEmbed("The channel must be a text channel.")] }); - } - - await newsTable.set(`${guild.id}.channel`, { - channelId: channel.id, - roleId: role?.id ?? "", - }); - - const embed = new EmbedBuilder() - .setTitle("✅ • News channel set!") - .setColor(genColor(100)); - - if (role) embed.setDescription(`The role <@&${role.id}> will be pinged when news are sent.`); - else embed.setDescription(`No role will be pinged when news are sent.`); - - await interaction.followUp({ embeds: [embed] }); - } -} diff --git a/src/commands/settings/news/edit.ts b/src/commands/settings/news/edit.ts deleted file mode 100644 index b6ed09a..0000000 --- a/src/commands/settings/news/edit.ts +++ /dev/null @@ -1,177 +0,0 @@ -import { - SlashCommandSubcommandBuilder, - EmbedBuilder, - PermissionsBitField, - type ChatInputCommandInteraction, - ModalBuilder, - TextInputBuilder, - ActionRowBuilder, - TextInputStyle, - TextChannel, - Message, -} from "discord.js"; -import { getNewsTable } from "../../../utils/database.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import sendSubscribedNewsletter, { News } from "../../../utils/sendSubscribedNews.js"; -import validateURL from "../../../utils/validateURL.js"; -import { QuickDB } from "quick.db"; - -export default class Edit { - data: SlashCommandSubcommandBuilder; - deferred: boolean = false; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("edit") - .setDescription("Edits new of your guild.") - .addStringOption((option) => - option - .setName("id") - .setDescription("The ID of the news you want to edit.") - .setRequired(true) - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const newsTable = await getNewsTable(db); - - const user = interaction.user; - const guild = interaction.guild; - const id = interaction.options.getString("id", true).trim(); - const news = await newsTable?.get(`${guild.id}.news.${id}`).then( - (news) => news as News - ).catch(() => null as News | null); - - if (!news) return await interaction.followUp({ embeds: [errorEmbed("The specified news doesn't exist.")] }); - - const author = user.displayName ?? user.username; - const timestamp = Date.now().toString(); - const member = guild.members.cache.get(user.id); - - if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await interaction.followUp({ - embeds: [errorEmbed("You need **Manage Server** permissions to add news.")], - }); - } - - const editModal = new ModalBuilder() - .setCustomId("editnews") - .setTitle("Edit News: " + news.title); - - const titleInput = new TextInputBuilder() - .setCustomId("title") - .setPlaceholder("Title") - .setStyle(TextInputStyle.Short) - .setMaxLength(100) - .setLabel("Title") - .setValue(news.title) - .setRequired(true); - - const bodyInput = new TextInputBuilder() - .setCustomId("body") - .setPlaceholder("Content (markdown)") - .setMaxLength(4000) - .setStyle(TextInputStyle.Paragraph) - .setLabel("Content (markdown)") - .setValue(news.body) - .setRequired(true); - - const imageURLInput = new TextInputBuilder() - .setCustomId("imageurl") - .setPlaceholder("Big image URL (bottom)") - .setStyle(TextInputStyle.Short) - .setMaxLength(1000) - .setLabel("Big image URL (bottom)") - .setValue(news.imageURL) - .setRequired(false); - - const firstActionRow = new ActionRowBuilder().addComponents(titleInput) as ActionRowBuilder; - const secondActionRow = new ActionRowBuilder().addComponents(bodyInput) as ActionRowBuilder; - const thirdActionRow = new ActionRowBuilder().addComponents(imageURLInput) as ActionRowBuilder; - - editModal.addComponents(firstActionRow, secondActionRow, thirdActionRow); - await interaction.showModal(editModal).catch((err) => { - console.error(err); - }); - - interaction.client.once("interactionCreate", async (interaction) => { - if (!interaction.isModalSubmit()) return; - if (interaction.customId !== "editnews") return; - - const title = interaction.fields.getTextInputValue("title") as string; - const body = interaction.fields.getTextInputValue("body") as string; - const imageURL = interaction.fields.getTextInputValue("imageurl") as string | undefined; - let validURL = false; - if (imageURL) validURL = validateURL(imageURL); - - if (!validURL && imageURL) { - await interaction.reply({ - embeds: [errorEmbed("The image URL you provided is invalid.")], - }); - return; - } - - const newNews = { - ...news, - title, - body, - imageURL, - author, - authorPfp: user.avatarURL(), - updatedAt: timestamp - }; - - sendSubscribedNewsletter(guild, { - ...newNews, - title: `Updated: ${newNews.title}`, - } as News).catch((err) => { - console.error(err); - }); - - const newsEmbed = new EmbedBuilder() - .setAuthor({ name: newNews.author, iconURL: newNews.authorPfp ?? null }) - .setTitle(newNews.title) - .setDescription(newNews.body) - .setImage(newNews.imageURL || null) - .setTimestamp(parseInt(newNews.updatedAt)) - .setFooter({ text: `Updated news from ${guild.name}` }) - .setColor(genColor(200)); - - const subscribedNewsChannel = await newsTable?.get(`${guild.id}.channel`).then( - (channel) => channel as { channelId: string; roleId: string } | null - ).catch(() => { - return { - channelId: null as string | null, - roleId: null as string | null - }; - }); - if (Boolean(subscribedNewsChannel.channelId)) { - const messageId = newNews?.messageId; - const newsChannel = (await guild.channels.fetch(subscribedNewsChannel?.channelId ?? "").catch(() => { })) as TextChannel | null; - - if (!messageId && newsChannel.id) { - newNews.messageId = ((await newsChannel?.send({ - embeds: [newsEmbed], - content: subscribedNewsChannel.roleId ? `<@&${subscribedNewsChannel.roleId}>` : null - }).catch(() => { })) as Message | null)?.id; - } else if (newsChannel.id) { - await newsChannel?.messages.edit(messageId, { - embeds: [newsEmbed], - content: subscribedNewsChannel.roleId ? `<@&${subscribedNewsChannel.roleId}>` : null - }).catch(() => { }); - } - } - - const embed = new EmbedBuilder() - .setTitle("✅ • News edited!") - .setColor(genColor(100)); - - await newsTable.set(`${guild.id}.news.${id}`, newNews); - await interaction.reply({ embeds: [embed] }); - }); - } -} diff --git a/src/commands/settings/news/remove.ts b/src/commands/settings/news/remove.ts deleted file mode 100644 index 934fc3a..0000000 --- a/src/commands/settings/news/remove.ts +++ /dev/null @@ -1,65 +0,0 @@ -import { - SlashCommandSubcommandBuilder, - EmbedBuilder, - PermissionsBitField, - type ChatInputCommandInteraction, - TextChannel, -} from "discord.js"; -import { getNewsTable } from "../../../utils/database.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { QuickDB } from "quick.db"; - -export default class Remove { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("remove") - .setDescription("Removes news from your guild.") - .addStringOption((option) => - option - .setName("id") - .setDescription("The id of the news (can be found in the footer of the news).") - .setRequired(true) - ); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const newsTable = await getNewsTable(db); - - const user = interaction.user; - const guild = interaction.guild; - const providedId = interaction.options.getString("id"); - const member = guild.members.cache.get(user.id); - - if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await interaction.followUp({ - embeds: [errorEmbed("You need **Manage Server** permissions to delete news.")], - }); - } - - const embed = new EmbedBuilder().setTitle("✅ • News deleted!").setColor(genColor(100)); - - const subscribedChannel = await newsTable?.get(`${guild.id}.channel`).then( - (channel) => channel as { channelId: string, roleId: string } - ).catch(() => { - return { - channelId: null, - roleId: null - } - }); - const news = await newsTable?.get(providedId).catch(() => null); - if (!news) return await interaction.followUp({ embeds: [errorEmbed("The specified news doesn't exist.")] }); - - const messageId = news?.messageId; - const newsChannel = (await interaction.guild.channels.fetch(subscribedChannel?.channelId ?? "").catch(() => null)) as TextChannel | null; - if (newsChannel) await newsChannel?.messages.delete(messageId).catch(() => null); - - await newsTable?.delete(`${guild.id}.news.${providedId}`).catch(() => null); - await interaction.followUp({ embeds: [embed] }); - } -} diff --git a/src/commands/settings/serverboard/invite.ts b/src/commands/settings/serverboard/invite.ts deleted file mode 100644 index 920b2d6..0000000 --- a/src/commands/settings/serverboard/invite.ts +++ /dev/null @@ -1,69 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import { getServerboardTable } from "../../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export default class Toggle { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("invite") - .setDescription("Toggles the invite link to your server in /info serverboard (Auto generates/deletes invites)."); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const serverbTable = await getServerboardTable(db); - - const rulesChannel = interaction.guild?.rulesChannel; - const guild = interaction.guild; - const member = guild.members.cache.get(interaction.user.id); - - if (!member.permissions.has(PermissionsBitField.Flags.ManageGuild)) - return await interaction.followUp({ embeds: [errorEmbed("You need **Manage Server** permissions to add an invite.")] }); - - const invite = await serverbTable?.get(`${guild.id}.invite`).then( - (invite) => String(invite) - ).catch(() => ""); - if (Boolean(invite)) { - // Delete invite - let invites = await rulesChannel?.fetchInvites(); - if (!rulesChannel) invites = await guild.invites.fetch(); - - if (invite) { - await invites.find(inv => inv.url == invite) - .delete("Serverboard invite disabled"); - } - - await serverbTable.delete(`${guild.id}.invite`); - - const embed = new EmbedBuilder() - .setTitle("✅ • Invite deleted!") - .setColor(genColor(100)); - - return await interaction.followUp({ embeds: [embed] }); - } - - if (!rulesChannel) return await interaction.followUp({ embeds: [errorEmbed("You need a **rules channel** to create an invite.")] }); - - const newInvite = await rulesChannel.createInvite({ - maxAge: 0, - maxUses: 0, - reason: "Serverboard invite enabled" - }); - await serverbTable.set(`${guild.id}.invite`, newInvite.url); - - const embed = new EmbedBuilder() - .setTitle("✅ • Invite created!") - .setColor(genColor(100)); - - await interaction.followUp({ embeds: [embed] }); - } -} diff --git a/src/commands/settings/serverboard/reveal.ts b/src/commands/settings/serverboard/reveal.ts deleted file mode 100644 index 5718317..0000000 --- a/src/commands/settings/serverboard/reveal.ts +++ /dev/null @@ -1,55 +0,0 @@ -import { - SlashCommandSubcommandBuilder, EmbedBuilder, PermissionsBitField, - type ChatInputCommandInteraction -} from "discord.js"; -import { genColor } from "../../../utils/colorGen.js"; -import errorEmbed from "../../../utils/embeds/errorEmbed.js"; -import database, { getServerboardTable } from "../../../utils/database.js"; -import { QuickDB } from "quick.db"; - -export default class Reveal { - data: SlashCommandSubcommandBuilder; - db: QuickDB; - - constructor(db?: QuickDB) { - this.db = db; - this.data = new SlashCommandSubcommandBuilder() - .setName("reveal") - .setDescription("Toggles the shown status of your server, community = shown, non = hidden (default)."); - } - - async run(interaction: ChatInputCommandInteraction) { - const db = this.db; - const serverbTable = await getServerboardTable(db); - - const guild = interaction.guild; - const isCommunity = guild?.rulesChannelId != null; - - const shown = await serverbTable?.get(`${guild?.id}.shown`).then( - (shown) => !!shown - ).catch(() => false); - const member = guild?.members.cache.get(interaction.user.id); - - if (!member?.permissions.has(PermissionsBitField.Flags.ManageGuild)) { - return await interaction.followUp({ - embeds: [errorEmbed("You need **Manage Server** permissions to toggle the shown status of your server.")], - }); - } - - if (!isCommunity) - await serverbTable.set(`${guild?.id}.shown`, !!shown).catch(() => { }); - else - await serverbTable.set(`${guild?.id}.shown`, !shown).catch(() => { }); - - const newShown = await serverbTable?.get(`${guild?.id}.shown`).then( - (shown) => !!shown - ).catch(() => false); - - const embed = new EmbedBuilder() - .setTitle("✅ • Shown status toggled!") - .setDescription(`Your server is now ${newShown ? "shown" : "hidden"}.`) - .setColor(genColor(100)); - - await interaction.followUp({ embeds: [embed] }); - } -} diff --git a/src/events/easterEggs.ts b/src/events/easterEggs.ts deleted file mode 100644 index 6046dde..0000000 --- a/src/events/easterEggs.ts +++ /dev/null @@ -1,20 +0,0 @@ -import type { Message } from "discord.js"; -import { pathToFileURL } from "url"; -import { join } from "path"; -import { readdirSync } from "fs"; - -export default { - name: "messageCreate", - event: class MessageCreate { - async run(message: Message) { - if (message.author.bot) return; - if (message.guildId !== "903852579837059113" && message.guildId !== "986268144446341142") return; - const eventsPath = join(process.cwd(), "src", "events"); - - for (const easterEggFile of readdirSync(join(eventsPath, "easterEggs"))) { - const msg = await import(pathToFileURL(join(eventsPath, "easterEggs", easterEggFile)).toString()); - new msg.default().run(message, ...message.content); - } - } - }, -}; diff --git a/src/events/easterEggs/Bread.ts b/src/events/easterEggs/Bread.ts deleted file mode 100644 index 0f30abc..0000000 --- a/src/events/easterEggs/Bread.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Message } from "discord.js"; -import multiReact from "../../utils/multiReact.js"; - -export default class Bread { - async run(message: Message) { - const gif = "https://tenor.com/bOMAb.gif"; - const randomizedChance = Math.round(Math.random() * 100); - let oddsForGif = 0.25; - - const breadSplit = message.content.toLowerCase().split("bread"); - if (breadSplit[1] == null) return; - - if ( - ((breadSplit[0].endsWith(" ") || breadSplit[0].endsWith("")) && breadSplit[1].startsWith(" ")) || - message.content.toLowerCase() === "bread" - ) { - if (randomizedChance <= oddsForGif) message.channel.send(gif); - else await multiReact(message, "🍞🇧🇷🇪🇦🇩👍"); - } - } -} diff --git a/src/events/easterEggs/Fan.ts b/src/events/easterEggs/Fan.ts deleted file mode 100644 index c48099b..0000000 --- a/src/events/easterEggs/Fan.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Message } from "discord.js"; -import randomise from "../../utils/randomise.js"; - -export default class Fan { - async run(message: Message) { - if (message.content.toLowerCase().includes("i'm a big fan")) { - const gifs = randomise([ - "https://tenor.com/bC37i.gif", - "https://tenor.com/view/fan-gif-20757784", - "https://tenor.com/view/below-deck-im-your-biggest-fan-biggest-fan-kate-kate-chastain-gif-15861715", - ]); - - await message.channel.send(gifs); - } - } -} diff --git a/src/events/easterEggs/Fireship.ts b/src/events/easterEggs/Fireship.ts deleted file mode 100644 index 2fd283c..0000000 --- a/src/events/easterEggs/Fireship.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Message } from "discord.js"; - -export default class FireShip { - async run(message: Message) { - const content = message.content.toLowerCase(); - - if ( - content.startsWith("this has been") && - content.endsWith("in 100 seconds") && - message.content !== "this has been in 100 seconds" - ) - await message.channel.send( - "hit the like button and subscribe if you want to see more short videos like this thanks for watching and I will see you in the next one" - ); - } -} diff --git a/src/events/easterEggs/Honk.ts b/src/events/easterEggs/Honk.ts deleted file mode 100644 index 1003288..0000000 --- a/src/events/easterEggs/Honk.ts +++ /dev/null @@ -1,8 +0,0 @@ -import type { Message } from "discord.js"; - -export default class Honk { - async run(message: Message) { - const honks: string[] = ["hnok", "hokn", "hkon", "onk", "hon", "honhk", "hhonk", "honkk"]; - if (honks.includes(message.content.toLowerCase())) message.channel.send("https://tenor.com/bW8sm.gif"); - } -} diff --git a/src/events/easterEggs/WhoPinged.ts b/src/events/easterEggs/WhoPinged.ts deleted file mode 100644 index 94b5d5d..0000000 --- a/src/events/easterEggs/WhoPinged.ts +++ /dev/null @@ -1,21 +0,0 @@ -import type { Message } from "discord.js"; -import randomise from "../../utils/randomise.js"; - -export default class WhoPinged { - async run(message: Message) { - if (message.content.toLowerCase().includes(`<@${message.client.user.id}>`)) { - const gifs = randomise([ - "https://tenor.com/view/who-pinged-me-ping-discord-up-opening-door-gif-20065356", - "https://tenor.com/view/discord-who-pinged-me-who-pinged-me-gif-25140226", - "https://tenor.com/view/who-pinged-me-ping-discord-discord-ping-undertaker-gif-20399650", - "https://tenor.com/view/who-pinged-me-ping-tudou-mr-potato-cat-gif-22762448", - "https://tenor.com/view/me-when-someone-pings-me-sad-cursed-emoji-crying-gif-22784322", - "https://tenor.com/view/discord-triggered-notification-angry-dog-noises-dog-girl-gif-11710406", - "https://tenor.com/view/tense-table-smash-mad-gif-13656077", - "https://tenor.com/view/yakuza-kazuma-kiryu-pissed-off-gif-14586175", - ]); - - await message.channel.send(gifs); - } - } -} diff --git a/src/events/easterEggs/amerikaYa.ts b/src/events/easterEggs/amerikaYa.ts new file mode 100644 index 0000000..99ae9a2 --- /dev/null +++ b/src/events/easterEggs/amerikaYa.ts @@ -0,0 +1,12 @@ +import type { Message, TextChannel } from "discord.js"; +import { randomise } from "../../utils/randomise"; + +export default async function run(message: Message) { + if (message.content.toLowerCase() != "amerika ya") return; + const response = randomise([ + "HALLO :D HALLO :D HALLO :D HALLO :D", + "https://tenor.com/view/america-ya-gif-15374592095658975433" + ]); + + await (message.channel as TextChannel).send(response); +} diff --git a/src/events/easterEggs/bread.ts b/src/events/easterEggs/bread.ts new file mode 100644 index 0000000..d7fd9a6 --- /dev/null +++ b/src/events/easterEggs/bread.ts @@ -0,0 +1,10 @@ +import type { Message, TextChannel } from "discord.js"; +import { multiReact } from "../../utils/multiReact"; + +export default async function run(message: Message) { + if (!message.content.toLowerCase().includes("bread")) return; + + if (Math.round(Math.random() * 100) <= 0.25) + (message.channel as TextChannel).send("https://tenor.com/bOMAb.gif"); + else await multiReact(message, "🍞🇧🇷🇪🇦🇩👍"); +} diff --git a/src/events/easterEggs/crazy.ts b/src/events/easterEggs/crazy.ts new file mode 100644 index 0000000..8ded6ec --- /dev/null +++ b/src/events/easterEggs/crazy.ts @@ -0,0 +1,9 @@ +import type { Message, TextChannel } from "discord.js"; + +export default async function run(message: Message) { + if (!message.content.toLowerCase().includes("crazy")) return; + if (Math.round(Math.random()) <= 0.5) + await (message.channel as TextChannel).send( + "Crazy? I was crazy once.\nThey locked me in a room.\nA rubber room.\nA rubber room with rats.\nAnd rats make me crazy.\nCrazy? I was crazy once..." + ); +} diff --git a/src/events/easterEggs/fan.ts b/src/events/easterEggs/fan.ts new file mode 100644 index 0000000..4d44dc6 --- /dev/null +++ b/src/events/easterEggs/fan.ts @@ -0,0 +1,13 @@ +import type { Message, TextChannel } from "discord.js"; +import { randomise } from "../../utils/randomise"; + +export default async function run(message: Message) { + if (message.content.toLowerCase() != "i'm a big fan") return; + const gifs = randomise([ + "https://tenor.com/bC37i.gif", + "https://tenor.com/view/fan-gif-20757784", + "https://tenor.com/view/below-deck-im-your-biggest-fan-biggest-fan-kate-kate-chastain-gif-15861715" + ]); + + await (message.channel as TextChannel).send(gifs); +} diff --git a/src/events/easterEggs/fire.ts b/src/events/easterEggs/fire.ts new file mode 100644 index 0000000..2a728fc --- /dev/null +++ b/src/events/easterEggs/fire.ts @@ -0,0 +1,14 @@ +import type { Message, TextChannel } from "discord.js"; +import { randomise } from "../../utils/randomise"; + +export default async function run(message: Message) { + if (message.content.toLowerCase() != "fire in the hole") return; + const gifs = randomise([ + "https://cdn.discordapp.com/attachments/799130520846991370/1080439680199315487/cat-chomp-fireball.gif?ex=65e8485d&is=65d5d35d&hm=cef8b83df8120f1419082f184d835c6af679c9d02d69f97e335eafa82b33489e&", + "https://tenor.com/view/dancing-gif-25178472", + "https://tenor.com/view/fire-in-the-hole-gif-11283103876805231056", + "https://tenor.com/view/fire-in-the-hole-gif-14799466830322850291" + ]); + + await (message.channel as TextChannel).send(gifs); +} diff --git a/src/events/easterEggs/fireship.ts b/src/events/easterEggs/fireship.ts new file mode 100644 index 0000000..9124f99 --- /dev/null +++ b/src/events/easterEggs/fireship.ts @@ -0,0 +1,13 @@ +import type { Message, TextChannel } from "discord.js"; + +export default async function run(message: Message) { + const content = message.content.toLowerCase(); + if ( + content.startsWith("this has been") && + content.endsWith("in 100 seconds") && + message.content != "this has been in 100 seconds" + ) + await (message.channel as TextChannel).send( + "hit the like button and subscribe if you want to see more short videos like this thanks for watching and I will see you in the next one" + ); +} diff --git a/src/events/easterEggs/honk.ts b/src/events/easterEggs/honk.ts new file mode 100644 index 0000000..9143205 --- /dev/null +++ b/src/events/easterEggs/honk.ts @@ -0,0 +1,7 @@ +import type { Message, TextChannel } from "discord.js"; + +export default async function run(message: Message) { + const honks = ["hnok", "hokn", "hkon", "onk", "hon", "honhk", "hhonk", "honkk"]; + if (!honks.includes(message.content.toLowerCase())) return; + (message.channel as TextChannel).send("https://tenor.com/bW8sm.gif"); +} diff --git a/src/events/easterEggs/whoPinged.ts b/src/events/easterEggs/whoPinged.ts new file mode 100644 index 0000000..cdd1eb7 --- /dev/null +++ b/src/events/easterEggs/whoPinged.ts @@ -0,0 +1,18 @@ +import type { Message, TextChannel } from "discord.js"; +import { randomise } from "../../utils/randomise"; + +export default async function run(message: Message) { + if (message.content.toLowerCase() != `<@${message.client.user.id}>`) return; + const gifs = randomise([ + "https://tenor.com/view/who-pinged-me-ping-discord-up-opening-door-gif-20065356", + "https://tenor.com/view/discord-who-pinged-me-who-pinged-me-gif-25140226", + "https://tenor.com/view/who-pinged-me-ping-discord-discord-ping-undertaker-gif-20399650", + "https://tenor.com/view/who-pinged-me-ping-tudou-mr-potato-cat-gif-22762448", + "https://tenor.com/view/me-when-someone-pings-me-sad-cursed-emoji-crying-gif-22784322", + "https://tenor.com/view/discord-triggered-notification-angry-dog-noises-dog-girl-gif-11710406", + "https://tenor.com/view/tense-table-smash-mad-gif-13656077", + "https://tenor.com/view/yakuza-kazuma-kiryu-pissed-off-gif-14586175" + ]); + + await (message.channel as TextChannel).send(gifs); +} diff --git a/src/events/error.ts b/src/events/error.ts deleted file mode 100644 index b593e5b..0000000 --- a/src/events/error.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { type Client } from "discord.js"; - -export default { - name: "error", - event: class Error { - client: Client; - - constructor(client: Client) { - this.client = client; - } - - async run(err) { - console.error(err); - } - } -} diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index 4b707a9..dd2242d 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,36 +1,34 @@ -import { EmbedBuilder, type Client, type Guild, DMChannel } from "discord.js"; -import Commands from "../handlers/commands.js"; -import randomise from "../utils/randomise.js"; -import { genColor } from "../utils/colorGen.js"; +import { EmbedBuilder, type DMChannel } from "discord.js"; +import { Commands } from "../handlers/commands"; +import { genColor } from "../utils/colorGen"; +import { randomise } from "../utils/randomise"; +import { Event } from "../utils/types"; -export default { - name: "guildCreate", - event: class GuildCreate { - client: Client; +export default (async function run(guild) { + const dmChannel = (await (await guild.fetchOwner()).createDM().catch(() => null)) as + | DMChannel + | undefined; - constructor(client: Client) { - this.client = client; - } + let emojis = ["💖", "💝", "💓", "💗", "💘", "💟", "💕", "💞"]; + if (Math.round(Math.random() * 100) <= 5) emojis = ["⌨️", "💻", "🖥️"]; - async run(guild: Guild) { - const owner = await guild.fetchOwner(); - const dmChannel = (await owner.createDM().catch(() => null)) as DMChannel | null;; - const hearts = ["💖", "💝", "💓", "💗", "💘", "💟", "💕", "💞"]; + const client = guild.client; + const embed = new EmbedBuilder() + .setAuthor({ + name: `Welcome to ${client.user.username}!`, + iconURL: client.user.displayAvatarURL() + }) + .setDescription( + [ + "Sokora is a multipurpose Discord bot that lets you manage your servers easily.", + "To manage the bot, use the **/settings** command.\n", + "Sokora is in an early stage of development. If you find bugs, please go to our [official server](https://discord.gg/c6C25P4BuY) and report them." + ].join("\n") + ) + .setFooter({ text: `Made with ${randomise(emojis)} by the Sokora team` }) + .setThumbnail(client.user.displayAvatarURL()) + .setColor(genColor(200)); - const embed = new EmbedBuilder() - .setTitle("👋 • Welcome to Nebula!") - .setDescription([ - "Nebula is a multiplatform, multipurpose bot with the ability to add extensions to have additional features.", - "To do things like disabling/enabling commands, use the **/settings** command.", - "As of now, it's in an early stage of development. If you find bugs, please go to our [official server](https://discord.gg/7RdABJhQss) to report them." - ].join("\n\n")) - .setFooter({ text: `Made by the Nebula team with ${randomise(hearts)}` }) - .setColor(genColor(200)); - - if (dmChannel) await dmChannel.send({ embeds: [embed] }); - - const commands = new Commands(guild.client); - await commands.registerCommandsForGuild(guild); - } - } -} + await new Commands(client).registerCommandsForGuild(guild); + if (dmChannel) await dmChannel.send({ embeds: [embed] }); +} as Event<"guildCreate">); diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts new file mode 100644 index 0000000..0d2ef0c --- /dev/null +++ b/src/events/guildMemberAdd.ts @@ -0,0 +1,35 @@ +import { EmbedBuilder, type TextChannel } from "discord.js"; +import { genColor } from "../utils/colorGen"; +import { getSetting } from "../utils/database/settings"; +import { imageColor } from "../utils/imageColor"; +import { replace } from "../utils/replace"; +import { Event } from "../utils/types"; + +export default (async function run(member) { + const guildID = member.guild.id; + const id = getSetting(guildID, "welcome", "channel") as string; + const user = member.user; + const avatar = member.displayAvatarURL(); + const embed = new EmbedBuilder() + .setAuthor({ name: `• ${user.displayName} has joined`, iconURL: avatar }) + .setFooter({ text: `User ID: ${member.id}` }) + .setThumbnail(avatar) + .setColor(member.user.hexAccentColor ?? (await imageColor(undefined, avatar)) ?? genColor(200)); + + if (id) { + const channel = (await member.guild.channels.cache + .find(channel => channel.id == id) + ?.fetch()) as TextChannel; + + replace(member, getSetting(guildID, "welcome", "join_text") as string, embed); + await channel.send({ embeds: [embed] }); + } + + if (!getSetting(guildID, "welcome", "join_dm") as boolean) return; + const dmChannel = await user.createDM().catch(() => null); + if (!dmChannel) return; + if (user.bot) return; + + replace(member, getSetting(guildID, "welcome", "dm_text") as string, embed); + await dmChannel.send({ embeds: [embed] }).catch(() => null); +} as Event<"guildMemberAdd">); diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts new file mode 100644 index 0000000..784f41d --- /dev/null +++ b/src/events/guildMemberRemove.ts @@ -0,0 +1,26 @@ +import { EmbedBuilder, type GuildMember, type TextChannel } from "discord.js"; +import { genColor } from "../utils/colorGen"; +import { getSetting } from "../utils/database/settings"; +import { imageColor } from "../utils/imageColor"; +import { replace } from "../utils/replace"; +import { Event } from "../utils/types"; + +export default (async function run(member: GuildMember) { + const guildID = member.guild.id; + const id = getSetting(guildID, "welcome", "channel") as string; + if (!id) return; + + const channel = (await member.guild.channels.cache + .find(channel => channel.id == id) + ?.fetch()) as TextChannel; + + const avatar = member.displayAvatarURL(); + const embed = new EmbedBuilder() + .setAuthor({ name: `• ${member.user.displayName} has left`, iconURL: avatar }) + .setFooter({ text: `User ID: ${member.id}` }) + .setThumbnail(avatar) + .setColor(member.user.hexAccentColor ?? (await imageColor(undefined, avatar)) ?? genColor(200)); + + replace(member, getSetting(guildID, "welcome", "leave_text") as string, embed); + await channel.send({ embeds: [embed] }); +} as Event<"guildMemberRemove">); diff --git a/src/events/interactionCreate.ts b/src/events/interactionCreate.ts new file mode 100644 index 0000000..ce203ca --- /dev/null +++ b/src/events/interactionCreate.ts @@ -0,0 +1,44 @@ +import { file } from "bun"; +import type { AutocompleteInteraction, CommandInteraction } from "discord.js"; +import { join } from "path"; +import { pathToFileURL } from "url"; +import { capitalize } from "../utils/capitalize"; +import { Event } from "../utils/types"; + +async function getCommand( + interaction: CommandInteraction | AutocompleteInteraction, + options: any +): Promise { + const commandName = capitalize(interaction.commandName)!; + const subcommandName = capitalize(options.getSubcommand(false)); + const commandGroupName = capitalize(options.getSubcommandGroup(false)); + let commandImportPath = join( + join(process.cwd(), "src", "commands"), + `${ + subcommandName + ? `${commandName.toLowerCase()}/${ + commandGroupName ? `${commandGroupName}/${subcommandName}` : subcommandName + }` + : commandName + }.ts` + ); + + if (!(await file(commandImportPath).exists())) + commandImportPath = join(join(process.cwd(), "src", "commands", `${commandName}.ts`)); + + return new (await import(pathToFileURL(commandImportPath).toString())).default(); +} + +export default (async function run(interaction) { + if (interaction.isChatInputCommand()) { + const command = await getCommand(interaction, interaction.options); + if (!command) return; + if (command.deferred) await interaction.deferReply(); + command.run(interaction); + } else if (interaction.isAutocomplete()) { + const command = await getCommand(interaction, interaction.options); + if (!command) return; + if (!command.autocomplete) return; + command.autocomplete(interaction); + } +} as Event<"interactionCreate">); diff --git a/src/events/leveling.ts b/src/events/leveling.ts deleted file mode 100644 index 54eccac..0000000 --- a/src/events/leveling.ts +++ /dev/null @@ -1,147 +0,0 @@ -import { ColorResolvable, EmbedBuilder, TextChannel, type Message } from "discord.js"; -import Vibrant from "node-vibrant"; -import sharp from "sharp"; -import { genColor, genRGBColor } from "../utils/colorGen.js"; -import database, { getLevelingTable, getSettingsTable } from "../utils/database.js"; -import { Reward } from "../commands/settings/leveling/rewards.js"; - -export const EXP_PER_MESSAGE = 10; // 10 exp per message -export const BASE_EXP_FOR_NEW_LEVEL = 10 * 100; // 1000 messages to level up -export const DIFFICULTY_MULTIPLIER = 1.25; // 1.25x harder to level up each level - -export default { - name: "messageCreate", - event: class MessageCreate { - db = null; - - async run(message: Message) { - if (!this.db) this.db = await database(); - const db = this.db; - const levelingTable = await getLevelingTable(db); - const settingsTable = await getSettingsTable(db); - const target = message.author; - - if (message.author.bot) return; - - const levelingEnabled = await settingsTable?.get(`${message.guild.id}.leveling.enabled`).then( - (enabled) => !!enabled - ).catch(() => false); - - if (!levelingEnabled) return; - - const { exp, levels } = await levelingTable?.get(`${message.guild.id}.${target.id}`).catch(() => { - return { - exp: 0, - levels: 0 - }; - }); - const { exp: expGlobal, levels: levelsGlobal } = await levelingTable?.get(`global.${target.id}`).then( - (data) => { - if (!data) return { - exp: 0, - levels: 0 - }; - return { - exp: Number(data.exp), - levels: Number(data.levels) - }; - } - ).catch(() => { - return { - exp: 0, - levels: 0 - }; - }); - - const expUntilLevelup = Math.floor(BASE_EXP_FOR_NEW_LEVEL * DIFFICULTY_MULTIPLIER * (levels + 1)); - const expUntilLevelupGlobal = Math.floor(BASE_EXP_FOR_NEW_LEVEL * DIFFICULTY_MULTIPLIER * (levelsGlobal + 1)); - const expUntilNextLevelup = Math.floor(BASE_EXP_FOR_NEW_LEVEL * DIFFICULTY_MULTIPLIER * (levels + 2)); - - const newLevelData = { - levels: levels ?? 0, - exp: (exp ?? 0) + EXP_PER_MESSAGE - }; - - const newLevelDataGlobal = { - levels: levelsGlobal ?? 0, - exp: (expGlobal ?? 0) + EXP_PER_MESSAGE - }; - - if (!(exp >= expUntilLevelup - 1)) { - await levelingTable.set(`global.${target.id}`, newLevelDataGlobal); - return await levelingTable.set(`${message.guild.id}.${target.id}`, newLevelDataGlobal); - } else if (exp >= expUntilLevelup - 1) { - let leftOverExp = exp - expUntilLevelup; - if (leftOverExp < 0) leftOverExp = 0; - - newLevelData.levels = levels + 1; - newLevelData.exp = leftOverExp ?? 0; - - await levelingTable.set(`${message.guild.id}.${target.id}`, newLevelData); - } - - if (exp >= expUntilLevelupGlobal - 1) { - let leftOverExpGlobal = exp - expUntilLevelup; - if (leftOverExpGlobal < 0) leftOverExpGlobal = 0; - - newLevelDataGlobal.levels = levels + 1; - newLevelDataGlobal.exp = leftOverExpGlobal + 1; - - await levelingTable.set(`global.${target.id}`, newLevelDataGlobal); - } - - // Check if there's a level up channel - const levelChannelId = await settingsTable?.get(`${message.guild.id}.leveling.channel`).then( - (channelId) => String(channelId) - ).catch(() => null); - if (!levelChannelId) return; - const levelChannel = message.guild.channels.cache.get(levelChannelId) as TextChannel - - // Level up embed - const leveledEmbed = new EmbedBuilder() - .setTitle("⚡ • Level Up!") - .setDescription([ - `**Congratulations <@${target.id}>**!`, - `You made it to **level ${levels + 1}**`, - `You need ${expUntilNextLevelup} exp to level up again.` - ].join("\n")) - .setAuthor({ - name: target.displayName, - url: target.avatarURL() - }) - .setThumbnail(target.avatarURL()) - .setColor(genColor(200)) - .setTimestamp(); - - // Get vibrant color - try { - const imageBuffer = await (await fetch(target.avatarURL())).arrayBuffer(); - const image = sharp(imageBuffer).toFormat("jpg"); - const { r, g, b } = (await new Vibrant(await image.toBuffer()).getPalette()).Vibrant; - leveledEmbed.setColor(genRGBColor(r, g, b) as ColorResolvable); - } catch { } - - // Sending the level up - levelChannel.send({ - embeds: [leveledEmbed], - content: `<@${target.id}>` - }); - - // Checking if there is a level reward - const levelRewards = await settingsTable?.get(`${message.guild.id}.leveling.rewards`).then( - (rewards) => rewards as Reward[] ?? [] as Reward[] - ).catch(() => [] as Reward[]); - const members = await message.guild.members.fetch(); - for (const { level, roleId } of levelRewards) { - const role = message.guild.roles.cache.get(roleId); - - if (levels >= level) { - await members.get(target.id)?.roles.add(role); - continue; - } - - await members.get(target.id)?.roles.remove(role); - } - } - } -} diff --git a/src/events/loadCommands.ts b/src/events/loadCommands.ts deleted file mode 100644 index 2530122..0000000 --- a/src/events/loadCommands.ts +++ /dev/null @@ -1,68 +0,0 @@ -import type { CommandInteraction, Interaction, Client, AutocompleteInteraction } from "discord.js"; -import { pathToFileURL } from "url"; -import { join } from "path"; -import database from "../utils/database.js"; -import { QuickDB } from "quick.db"; - -const COMMANDS_PATH = join(process.cwd(), "src", "commands"); - -async function getCommand(interaction: CommandInteraction | AutocompleteInteraction, options: any, db: QuickDB): Promise { - const commandName = interaction.commandName; - const subcommandName = options.getSubcommand(false); - const commandgroupName = options.getSubcommandGroup(false); - - const commandImportPath = join( - COMMANDS_PATH, - `${subcommandName ? - `${commandName}/${commandgroupName ? - `${commandgroupName}/${subcommandName}` : - subcommandName}` : - commandName}.ts` - ); - const command = new (await import(pathToFileURL(commandImportPath).toString())).default(db); - return command; -} - -export default { - name: "interactionCreate", - event: class InteractionCreate { - commands: CommandInteraction; - client: Client; - lastOpenedDb = Date.now(); - db: QuickDB = null; - - constructor(cmds: CommandInteraction, client: Client) { - this.commands = cmds; - this.client = client; - this.lastOpenedDb = Date.now(); - } - - async run(interaction: Interaction) { - if (!this.db) this.db = await database(); - if (Date.now() - this.lastOpenedDb > 1000 * 60 * 60) { - this.db = await database(); - this.lastOpenedDb = Date.now(); - } - - if (interaction.isChatInputCommand()) { - const options = interaction.options - - // Command names - const command = await getCommand(interaction, options, this.db).catch(() => null); - if (!command) return; - - const deferred = command?.deferred ?? true; - if (deferred) await interaction.deferReply(); - - command.run(interaction); - } else if (interaction.isAutocomplete()) { - const options = interaction.options; - - const command = await getCommand(interaction, options, this.db).catch(() => null); - if (!command) return; - if (!command.autocomplete) return; - command.autocomplete(interaction); - } - } - } -}; diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts new file mode 100644 index 0000000..333e5dc --- /dev/null +++ b/src/events/messageCreate.ts @@ -0,0 +1,82 @@ +import { EmbedBuilder, type TextChannel } from "discord.js"; +import { readdirSync } from "fs"; +import { join } from "path"; +import { pathToFileURL } from "url"; +import { genColor } from "../utils/colorGen"; +import { getLevel, setLevel } from "../utils/database/leveling"; +import { getSetting } from "../utils/database/settings"; +import { kominator } from "../utils/kominator"; +import { Event } from "../utils/types"; + +const cooldowns = new Map(); +export default (async function run(message) { + const author = message.author; + if (author.bot) return; + const guild = message.guild!; + + // Easter egg handler + if (getSetting(guild.id, "easter", "enabled")) { + const eventsPath = join(process.cwd(), "src", "events", "easterEggs"); + + for (const easterEggFile of readdirSync(eventsPath)) + (await import(pathToFileURL(join(eventsPath, easterEggFile)).toString())).default(message); + } + + // Leveling + if (!getSetting(guild.id, "leveling", "enabled")) return; + + const blockedChannels = getSetting(guild.id, "leveling", "block_channels") as string; + if (blockedChannels != undefined) + for (const channelID of kominator(blockedChannels)) if (message.channelId == channelID) return; + + const cooldown = getSetting(guild.id, "leveling", "cooldown") as number; + if (cooldown > 0) { + const key = `${guild.id}-${author.id}`; + const lastExpTime = cooldowns.get(key) || 0; + const now = Date.now(); + + if (now - lastExpTime < cooldown * 1000) return; + else cooldowns.set(key, now); + } + + const xpGain = getSetting(guild.id, "leveling", "xp_gain") as number; + const levelChannelId = getSetting(guild.id, "leveling", "channel"); + const difficulty = getSetting(guild.id, "leveling", "difficulty") as number; + const [level, xp] = getLevel(guild.id, author.id); + const xpUntilLevelUp = Math.floor( + 100 * difficulty * (level + 1) ** 2 - 85 * difficulty * level ** 2 + ); + const newLevelData = { level: level ?? 0, xp: xp + xpGain }; + + if (newLevelData.xp < xpUntilLevelUp) + return setLevel(guild.id, author.id, newLevelData.level, newLevelData.xp); + + while ( + newLevelData.xp >= + 100 * difficulty * (newLevelData.level + 1) ** 2 - 85 * difficulty * newLevelData.level ** 2 + ) + newLevelData.level++; + + setLevel(guild.id, author.id, newLevelData.level, newLevelData.xp); + const embed = new EmbedBuilder() + .setAuthor({ + name: `• ${author.displayName} has levelled up!`, + iconURL: author.displayAvatarURL() + }) + .setDescription( + [ + `**Congratulations, ${author.displayName}**!`, + `You made it to **level ${level + 1}**`, + `You need ${Math.floor(100 * difficulty * (level + 2))} XP to level up again.` + ].join("\n") + ) + .setThumbnail(author.displayAvatarURL()) + .setTimestamp() + .setColor(genColor(200)); + + if (levelChannelId) + (guild.channels.cache.get(`${levelChannelId}`) as TextChannel).send({ + embeds: [embed], + content: `<@${author.id}>` + }); +} as Event<"messageCreate">); diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts new file mode 100644 index 0000000..3f2d05f --- /dev/null +++ b/src/events/messageDelete.ts @@ -0,0 +1,27 @@ +import { codeBlock, EmbedBuilder } from "discord.js"; +import { genColor } from "../utils/colorGen"; +import { getSetting } from "../utils/database/settings"; +import { logChannel } from "../utils/logChannel"; +import { Event } from "../utils/types"; + +export default (async function run(message) { + const author = message.author!; + if (author.bot) return; + + const guild = message.guild!; + if (!getSetting(guild.id, "moderation", "log_messages")) return; + + const embed = new EmbedBuilder() + .setAuthor({ + name: `• ${author.displayName}'s message has been deleted.`, + iconURL: author.displayAvatarURL() + }) + .addFields({ + name: "🗞️ • Deleted message", + value: codeBlock(message.content!) + }) + .setFooter({ text: `Message ID: ${message.id}\nUser ID: ${author.id}` }) + .setColor(genColor(60)); + + await logChannel(guild, embed); +} as Event<"messageDelete">); diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts new file mode 100644 index 0000000..f76ff05 --- /dev/null +++ b/src/events/messageUpdate.ts @@ -0,0 +1,37 @@ +import { codeBlock, EmbedBuilder } from "discord.js"; +import { genColor } from "../utils/colorGen"; +import { getSetting } from "../utils/database/settings"; +import { logChannel } from "../utils/logChannel"; +import { Event } from "../utils/types"; + +export default (async function run(oldMessage, newMessage) { + const author = oldMessage.author!; + if (author.bot) return; + + const guild = oldMessage.guild!; + if (!getSetting(guild.id, "moderation", "log_messages")) return; + + const oldContent = oldMessage.content!; + const newContent = newMessage.content!; + if (oldContent == newContent) return; + + const embed = new EmbedBuilder() + .setAuthor({ + name: `• ${author.displayName}'s message has been edited`, + iconURL: author.displayAvatarURL() + }) + .addFields( + { + name: "🕰️ • Old message", + value: codeBlock(oldContent) + }, + { + name: "🔄️ • New message", + value: codeBlock(newContent) + } + ) + .setFooter({ text: `Message ID: ${oldMessage.id}\nUser ID: ${author.id}` }) + .setColor(genColor(60)); + + await logChannel(guild, embed); +} as Event<"messageUpdate">); diff --git a/src/handlers/commands.ts b/src/handlers/commands.ts index 6e1e78e..792dbb3 100644 --- a/src/handlers/commands.ts +++ b/src/handlers/commands.ts @@ -1,43 +1,49 @@ -import { SlashCommandBuilder, SlashCommandSubcommandGroupBuilder, Guild, type Client } from "discord.js"; -import { pathToFileURL } from "url"; -import { join } from "path"; +import { + Guild, + SlashCommandBuilder, + SlashCommandSubcommandGroupBuilder, + type Client +} from "discord.js"; import { readdirSync } from "fs"; -import database, { getSettingsTable } from "../utils/database.js"; -import { QuickDB } from "quick.db"; +import { join } from "path"; +import { pathToFileURL } from "url"; +import { getDisabledCommands } from "../utils/database/disabledCommands"; -const COMMANDS_PATH = join(process.cwd(), "src", "commands"); -export default class Commands { +export class Commands { client: Client; commands: any[] = []; - db: QuickDB; - - constructor(client?: Client) { + constructor(client: Client) { this.client = client; } - // Load the commands into this.commands - private async createSubCommand(name: string, ...disabledCommands: string[]): Promise { + private async createSubCommand( + name: string, + ...disabledCommands: string[] + ): Promise { + const commandsPath = join(process.cwd(), "src", "commands"); const command = new SlashCommandBuilder() .setName(name.toLowerCase()) .setDescription("This command has no description."); - // Fetch the subcommands - const subCommandFiles = readdirSync(join(COMMANDS_PATH, name), { withFileTypes: true }); - - // Add the subcommands to the top command - for (const subCommandFile of subCommandFiles) { + for (const subCommandFile of readdirSync(join(commandsPath, name), { + withFileTypes: true + })) { const subCommandName = subCommandFile.name.replaceAll(".ts", ""); if ( disabledCommands?.find( - (command) => command?.split("/")?.[0] == name && command?.split("/")?.[1] == subCommandName + command => command?.split("/")?.[0] == name && command?.split("/")?.[1] == subCommandName ) - ) continue; + ) + continue; if (subCommandFile.isFile()) { - const subCommand = await import(pathToFileURL(join(COMMANDS_PATH, name, subCommandFile.name)).toString()); - try { - command.addSubcommand(new subCommand.default().data); - } catch {} + const subCommandModule = await import( + pathToFileURL(join(commandsPath, name, subCommandFile.name)).toString() + ); + const subCommand = new subCommandModule.default(); + + command.addSubcommand(subCommand.data); + if ("autocompleteHandler" in subCommand) subCommand.autocompleteHandler(this.client); continue; } @@ -45,21 +51,25 @@ export default class Commands { .setName(subCommandName.toLowerCase()) .setDescription("This subcommand group has no description."); - const subCommandGroupFiles = readdirSync(join(COMMANDS_PATH, name, subCommandFile.name), { withFileTypes: true }); - + const subCommandGroupFiles = readdirSync(join(commandsPath, name, subCommandFile.name), { + withFileTypes: true + }); for (const subCommandGroupFile of subCommandGroupFiles) { if (!subCommandGroupFile.isFile()) continue; if ( disabledCommands?.find( - (command) => + command => command?.split("/")?.[0] == name && command?.split("/")?.[1] == subCommandFile.name.replaceAll(".ts", "") && command?.split("/")?.[2] == subCommandGroupFile.name.replaceAll(".ts", "") ) - ) continue; + ) + continue; const subCommand = await import( - pathToFileURL(join(COMMANDS_PATH, name, subCommandFile.name, subCommandGroupFile.name)).toString() + pathToFileURL( + join(commandsPath, name, subCommandFile.name, subCommandGroupFile.name) + ).toString() ); subCommandGroup.addSubcommand(new subCommand.default().data); } @@ -70,46 +80,40 @@ export default class Commands { } async loadCommands(...disabledCommands: string[]) { + const commandsPath = join(process.cwd(), "src", "commands"); this.commands = []; - const commandFiles = readdirSync(COMMANDS_PATH, { withFileTypes: true }); + const commandFiles = readdirSync(commandsPath, { withFileTypes: true }); for (const commandFile of commandFiles) { const name = commandFile.name; if (disabledCommands?.includes(name.replaceAll(".ts", ""))) continue; if (commandFile.isFile()) { - // Add the commands it found to the list - const command = await import(pathToFileURL(join(COMMANDS_PATH, name)).toString()); - this.commands.push(new command.default().data); + const commandImport = await import(pathToFileURL(join(commandsPath, name)).toString()); + this.commands.push(new commandImport.default().data); continue; } - // Folder found -> Make subcommand - // Create a top subcommand - const subCommand = await this.createSubCommand(name, join(COMMANDS_PATH, name), ...disabledCommands); + const subCommand = await this.createSubCommand( + name, + join(commandsPath, name), + ...disabledCommands + ); this.commands.push(subCommand); } } - // Register the commands for a specific guild async registerCommandsForGuild(guild: Guild, ...disabledCommands: string[]) { await this.loadCommands(...disabledCommands); await guild.commands.set(this.commands); } - // Register the commands for all guilds async registerCommands(): Promise { - const db = await database(); await this.loadCommands(); - const settingsTable = await getSettingsTable(db); const guilds = this.client.guilds.cache; - // Adding the commands to the guilds - console.log("Adding commands to guilds..."); for (const guildID of guilds.keys()) { - const disabledCommands = await settingsTable?.get(`${guildID}.disabledCommands`).then( - (disabledCommands: string[]) => disabledCommands as string[] ?? [] as string[] - ).catch(() => [] as string[]); + const disabledCommands = getDisabledCommands(guildID); if (disabledCommands.length > 0) await this.loadCommands(...disabledCommands); await guilds.get(guildID)?.commands.set(this.commands); } diff --git a/src/handlers/events.ts b/src/handlers/events.ts index c7964f9..3ac1b39 100644 --- a/src/handlers/events.ts +++ b/src/handlers/events.ts @@ -1,26 +1,26 @@ import type { Client } from "discord.js"; -import { pathToFileURL } from "url"; -import { join } from "path"; import { readdirSync } from "fs"; +import { join } from "path"; +import { pathToFileURL } from "url"; -export default class Events { +export class Events { client: Client; events: any[] = []; - constructor(client: Client) { this.client = client; + } - (async () => { - const eventsPath = join(process.cwd(), "src", "events"); + async loadEvents() { + const eventsPath = join(process.cwd(), "src", "events"); - for (const eventFile of readdirSync(eventsPath)) { - if (!eventFile.endsWith("js")) continue; + for (const eventFile of readdirSync(eventsPath)) { + if (!eventFile.endsWith("ts")) continue; - const event = await import(pathToFileURL(join(eventsPath, eventFile)).toString()); - const clientEvent = this.client.on(event.default.name, new event.default.event(this.client).run); + const event = (await import(pathToFileURL(join(eventsPath, eventFile)).toString())).default; + const eventName = eventFile.split(".ts")[0]; + const clientEvent = this.client.on(eventName, event); - this.events.push({ name: event.default.name, event: clientEvent }); - } - })(); + this.events.push({ name: eventName, event: clientEvent }); + } } } diff --git a/src/index.ts b/src/index.ts index 969a9d9..e4be99a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,6 @@ import { ShardingManager } from "discord.js"; const manager = new ShardingManager("./src/bot.ts", { token: process.env.TOKEN }); - manager.on("shardCreate", shard => { shard.on("error", err => console.error(err)); console.log(`Launched shard ${shard.id}`); diff --git a/src/utils/capitalize.ts b/src/utils/capitalize.ts new file mode 100644 index 0000000..9c2d7cf --- /dev/null +++ b/src/utils/capitalize.ts @@ -0,0 +1,9 @@ +/** + * Outputs the string with the first letter capitalized. + * @param string String, the first letter of which should be capitalized. + */ + +export function capitalize(string: string) { + if (!string) return; + return `${string.charAt(0).toUpperCase()}${string.slice(1)}`; +} diff --git a/src/utils/colorGen.ts b/src/utils/colorGen.ts index 018a46d..c00e6b0 100644 --- a/src/utils/colorGen.ts +++ b/src/utils/colorGen.ts @@ -28,14 +28,15 @@ export function genColor(hue: number) { * @param b Blue. * @returns Color in HEX. */ -export function genRGBColor(r, g, b) { + +export function genRGBColor(r: any, g: any, b: any) { r = r.toString(16); g = g.toString(16); b = b.toString(16); - if (r.length === 1) r = "0" + r; - if (g.length === 1) g = "0" + g; - if (b.length === 1) b = "0" + b; + if (r.length == 1) r = `0${r}`; + if (g.length == 1) g = `0${g}`; + if (b.length == 1) b = `0${b}`; return `#${r}${g}${b}`; } diff --git a/src/utils/database.ts b/src/utils/database.ts deleted file mode 100644 index 52e5764..0000000 --- a/src/utils/database.ts +++ /dev/null @@ -1,25 +0,0 @@ -import { QuickDB, MySQLDriver } from "quick.db"; - -async function database() { - const mysql = new MySQLDriver({ - host: process.env.MYSQL_HOST, - user: process.env.MYSQL_USERNAME, - port: Number(process.env.MYSQL_PORT), - password: process.env.MYSQL_PASSWORD, - database: process.env.MYSQL_DATABASE, - enableKeepAlive: true, - compress: true - }); - - await mysql.connect(); - const db = new QuickDB({ driver: mysql }); - return db; -} - -export const getSettingsTable = async (db: QuickDB) => await db.tableAsync("settings"); -export const getLevelingTable = async (db: QuickDB) => await db.tableAsync("leveling"); -export const getServerboardTable = async (db: QuickDB) => await db.tableAsync("serverboard"); -export const getNewsTable = async (db: QuickDB) => await db.tableAsync("news"); -export const getModerationTable = async (db: QuickDB) => await db.tableAsync("moderation"); - -export default database; diff --git a/src/utils/database/disabledCommands.ts b/src/utils/database/disabledCommands.ts new file mode 100644 index 0000000..d0e05dd --- /dev/null +++ b/src/utils/database/disabledCommands.ts @@ -0,0 +1,31 @@ +import { getDatabase } from "."; +import { TableDefinition, TypeOfDefinition } from "./types"; + +const tableDefinition = { + name: "disabledCommands", + definition: { + guild: "TEXT", + command: "TEXT" + } +} satisfies TableDefinition; + +const database = getDatabase(tableDefinition); +const getQuery = database.query("SELECT * FROM disabledCommands WHERE guild = $1;"); +const addQuery = database.query("INSERT INTO disabledCommands (guild, command) VALUES (?1, ?2);"); +const removeQuery = database.query( + "DELETE FROM disabledCommands WHERE guild = $1 AND command = $2" +); + +export function getDisabledCommands(guildID: string) { + return (getQuery.all(guildID) as TypeOfDefinition[]).map( + val => val.command + ); +} + +export function disableCommands(guildID: string, command: string) { + addQuery.run(guildID, command); +} + +export function enableCommands(guildID: string, command: string) { + removeQuery.run(guildID, command); +} diff --git a/src/utils/database/index.ts b/src/utils/database/index.ts new file mode 100644 index 0000000..27713ac --- /dev/null +++ b/src/utils/database/index.ts @@ -0,0 +1,14 @@ +import { Database } from "bun:sqlite"; +import { TableDefinition } from "./types"; + +// Get (or create) an SQLite database +const database = new Database("data.db", { create: true }); + +export function getDatabase(definition: TableDefinition) { + // Create table if it doesn't exist + const defStr = Object.entries(definition.definition) + .map(([field, type]) => field.concat(" ", type)) + .join(", "); + database.run(`CREATE TABLE IF NOT EXISTS ${definition.name} (${defStr});`); + return database; +} diff --git a/src/utils/database/levelBlockedChannels.ts b/src/utils/database/levelBlockedChannels.ts new file mode 100644 index 0000000..3bcf30a --- /dev/null +++ b/src/utils/database/levelBlockedChannels.ts @@ -0,0 +1,40 @@ +import { getDatabase } from "."; +import { TableDefinition, TypeOfDefinition } from "./types"; + +const tableDefinition = { + name: "levelBlockedChannels", + definition: { + guild: "TEXT", + channel: "TEXT" + } +} satisfies TableDefinition; + +const database = getDatabase(tableDefinition); +const getQuery = database.query( + "SELECT * FROM levelBlockedChannels WHERE guild = $1 AND channel = $2;" +); +const listQuery = database.query("SELECT * FROM levelBlockedChannels WHERE guild = $1;"); +const addQuery = database.query( + "INSERT INTO levelBlockedChannels (guild, channel) VALUES (?1, ?2);" +); +const removeQuery = database.query( + "DELETE FROM levelBlockedChannels WHERE guild = $1 AND channel = $2;" +); + +export function getBlockedChannels(guildID: string, channelID: string) { + return getQuery.all(guildID, channelID).length == 0; +} + +export function listBlockedChannels(guildID: string) { + return (listQuery.all(guildID) as TypeOfDefinition[]).map( + val => val.channel + ); +} + +export function blockChannel(guildID: string, channelID: string) { + addQuery.run(guildID, channelID); +} + +export function unblockChannel(guildID: string, channelID: string) { + removeQuery.run(guildID, channelID); +} diff --git a/src/utils/database/levelRewards.ts b/src/utils/database/levelRewards.ts new file mode 100644 index 0000000..9255f80 --- /dev/null +++ b/src/utils/database/levelRewards.ts @@ -0,0 +1,36 @@ +// TODO: Implement logic +import { getDatabase } from "."; +import { TableDefinition, TypeOfDefinition } from "./types"; + +const tableDefinition = { + name: "levelRewards", + definition: { + guild: "TEXT", + roleID: "TEXT", + level: "INTEGER" + } +} satisfies TableDefinition; + +const database = getDatabase(tableDefinition); +const getQuery = database.query("SELECT * FROM levelRewards WHERE guild = $1;"); +const addQuery = database.query( + "INSERT INTO levelRewards (guild, roleID, level) VALUES (?1, ?2, ?3);" +); +const removeQuery = database.query("DELETE FROM levelRewards WHERE guild = $1 AND roleID = $2"); + +export function get(guildID: string) { + return getQuery.all(guildID) as TypeOfDefinition[]; +} + +export function addReward(guildID: string, role: number | string, level: number) { + return addQuery.all(guildID, level, role) as TypeOfDefinition[]; +} + +export function updateReward(guildID: string, role: number | string, level: number) { + removeQuery.run(guildID, role); + addQuery.all(guildID, level, role); +} + +export function removeReward(guildID: number | string, role: number | string) { + removeQuery.run(guildID, role); +} diff --git a/src/utils/database/leveling.ts b/src/utils/database/leveling.ts new file mode 100644 index 0000000..1a23d53 --- /dev/null +++ b/src/utils/database/leveling.ts @@ -0,0 +1,37 @@ +import { getDatabase } from "."; +import { TableDefinition, TypeOfDefinition } from "./types"; + +const tableDefinition = { + name: "leveling", + definition: { + guild: "TEXT", + user: "TEXT", + level: "INTEGER", + xp: "INTEGER" + } +} satisfies TableDefinition; + +const database = getDatabase(tableDefinition); + +const getQuery = database.query("SELECT * FROM leveling WHERE guild = $1 AND user = $2;"); +const deleteQuery = database.query("DELETE FROM leveling WHERE guild = $1 AND user = $2;"); +const insertQuery = database.query( + "INSERT INTO leveling (guild, user, level, xp) VALUES (?1, ?2, ?3, ?4);" +); + +const getGuildQuery = database.query("SELECT * FROM leveling WHERE guild = $1;"); + +export function getLevel(guildID: string, userID: string): [number, number] { + const res = getQuery.all(guildID, userID) as TypeOfDefinition[]; + if (!res.length) return [0, 0]; + return [res[0].level, res[0].xp]; +} + +export function setLevel(guildID: string | number, userID: string, level: number, xp: number) { + if (getQuery.all(guildID, userID).length) deleteQuery.run(guildID, userID); + insertQuery.run(guildID, userID, level, xp); +} + +export function getGuildLeaderboard(guildID: string): TypeOfDefinition[] { + return getGuildQuery.all(guildID) as TypeOfDefinition[]; +} diff --git a/src/utils/database/moderation.ts b/src/utils/database/moderation.ts new file mode 100644 index 0000000..ce3fc90 --- /dev/null +++ b/src/utils/database/moderation.ts @@ -0,0 +1,86 @@ +import { getDatabase } from "."; +import { TableDefinition, TypeOfDefinition } from "./types"; + +const definition = { + name: "moderation", + definition: { + guild: "TEXT", + user: "TEXT", + type: "TEXT", + moderator: "TEXT", + reason: "TEXT", + id: "TEXT", + timestamp: "TIMESTAMP", + expiresAt: "TIMESTAMP" + } +} satisfies TableDefinition; + +export type modType = "MUTE" | "WARN" | "KICK" | "BAN"; +const database = getDatabase(definition); +const addQuery = database.query( + "INSERT INTO moderation (guild, user, type, moderator, reason, id, timestamp, expiresAt) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8);" +); +const listGuildQuery = database.query("SELECT * FROM moderation WHERE guild = $1"); +const listUserQuery = database.query("SELECT * FROM moderation WHERE guild = $1 AND user = $2;"); +const listUserTypeQuery = database.query( + "SELECT * FROM moderation WHERE guild = $1 AND user = $2 AND type = $3;" +); +const listModQuery = database.query( + "SELECT * FROM moderation WHERE guild = $1 AND moderator = $2;" +); +const getIdQuery = database.query("SELECT * FROM moderation WHERE guild = $1 AND id = $2;"); +const removeQuery = database.query("DELETE FROM moderation WHERE guild = $1 AND id = $2"); + +export function addModeration( + guildID: string | number, + userID: string, + type: modType, + moderator: string, + reason = "", + expiresAt?: number | null +) { + const id = listGuildQuery.all(guildID).length + 1; + addQuery.run(guildID, userID, type, moderator, reason, id, Date.now(), expiresAt ?? null); + return id; +} + +export function listUserModeration( + guildID: number | string, + userID: number | string, + type?: modType +) { + if (type) + return listUserTypeQuery.all(guildID, userID, type) as TypeOfDefinition[]; + + return listUserQuery.all(guildID, userID) as TypeOfDefinition[]; +} + +export function getModeration(guildID: number | string, userID: number | string, id: string) { + const modCase = getIdQuery.all(guildID, id) as TypeOfDefinition[]; + if (modCase[0].user == userID) return modCase; + return []; +} + +export function listModeratorLog(guildID: number | string, moderator: number | string) { + return listModQuery.all(guildID, moderator) as TypeOfDefinition[]; +} + +export function removeModeration(guildID: string | number, id: string) { + removeQuery.run(guildID, id); +} + +const getExpiredBansQuery = database.query( + "SELECT * FROM moderation WHERE type = 'BAN' AND expiresAt IS NOT NULL AND expiresAt <= $1;" +); + +export function getExpiredBans(currentTime: number) { + return getExpiredBansQuery.all(currentTime) as TypeOfDefinition[]; +} + +const getPendingBansQuery = database.query( + "SELECT * FROM moderation WHERE type = 'BAN' AND expiresAt IS NOT NULL AND expiresAt > $1;" +); + +export function getPendingBans(currentTime: number) { + return getPendingBansQuery.all(currentTime) as TypeOfDefinition[]; +} diff --git a/src/utils/database/news.ts b/src/utils/database/news.ts new file mode 100644 index 0000000..bfff50d --- /dev/null +++ b/src/utils/database/news.ts @@ -0,0 +1,65 @@ +import { getDatabase } from "."; +import { TableDefinition, TypeOfDefinition } from "./types"; + +const definition = { + name: "news", + definition: { + guildID: "TEXT", + title: "TEXT", + body: "TEXT", + author: "TEXT", + authorPFP: "TEXT", + createdAt: "TIMESTAMP", + updatedAt: "TIMESTAMP", + messageID: "TEXT", + id: "TEXT" + } +} satisfies TableDefinition; + +const database = getDatabase(definition); +const sendQuery = database.query( + "INSERT INTO news (guildID, title, body, author, authorPFP, createdAt, updatedAt, messageID, id) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9);" +); +export const listAllQuery = database.query("SELECT * FROM news WHERE guildID = $1;"); +const getIdQuery = database.query("SELECT * FROM news WHERE id = $1;"); +const deleteQuery = database.query("DELETE FROM news WHERE id = $1"); + +export function addNews( + guildID: string, + title: string, + body: string, + author: string, + authorPFP: string, + messageID: string, + id: string +) { + sendQuery.run(guildID, title, body, author, authorPFP, Date.now(), 0, messageID, id); +} + +export function listAllNews(guildID: string) { + return listAllQuery.all(guildID) as TypeOfDefinition[]; +} + +export function get(id: string) { + return getIdQuery.get(id) as TypeOfDefinition | null; +} + +export function updateNews(id: string, title?: string, body?: string, messageID?: string) { + const lastElem = get(id)!; + deleteQuery.run(id); + sendQuery.run( + lastElem.guildID, + title ?? lastElem.title, + body ?? lastElem.body, + lastElem.author, + lastElem.authorPFP, + Date.now(), + 0, + messageID ?? lastElem.messageID, + id + ); +} + +export function deleteNews(id: string) { + deleteQuery.run(id); +} diff --git a/src/utils/database/settings.ts b/src/utils/database/settings.ts new file mode 100644 index 0000000..d5679f6 --- /dev/null +++ b/src/utils/database/settings.ts @@ -0,0 +1,197 @@ +import { getDatabase } from "."; +import { kominator } from "../kominator"; +import { FieldData, SqlType, TableDefinition, TypeOfDefinition } from "./types"; + +const tableDefinition = { + name: "settings", + definition: { + guildID: "TEXT", + key: "TEXT", + value: "TEXT" + } +} satisfies TableDefinition; + +export const settingsDefinition: Record< + string, + { + description: string; + settings: Record; + } +> = { + leveling: { + description: "Customise the behaviour of the leveling system.", + settings: { + enabled: { + type: "BOOL", + desc: "Enable/disable the leveling system.", + val: true + }, + channel: { + type: "TEXT", + desc: "ID of the log channel for leveling-related stuff (i.e someone leveling up)." + }, + block_channels: { + type: "TEXT", + desc: "ID(s) of the channels where messages aren't counted, comma separated." + }, + xp_gain: { + type: "INTEGER", + desc: "Set the amount of XP a user gains per message.", + val: 5 + }, + cooldown: { + type: "INTEGER", + desc: "Set the cooldown between messages that add XP.", + val: 2 + }, + difficulty: { + type: "INTEGER", + desc: "Set the difficulty (ex: 2 will make it 2x harder to level up).", + val: 1 + } + } + }, + moderation: { + description: "Change where Sokora sends moderation logs.", + settings: { + channel: { + type: "TEXT", + desc: "ID of the log channel for moderation-related stuff (i.e a message being edited)." + }, + log_messages: { + type: "BOOL", + desc: "Whether or not edited/deleted messages should be logged.", + val: true + } + } + }, + news: { + description: "Configure news for your server.", + settings: { + channel_id: { + type: "TEXT", + desc: "ID of the channel where news messages are sent." + }, + role_id: { + type: "TEXT", + desc: "ID of the roles that should be pinged when a news message is sent." + }, + edit_original_message: { + type: "BOOL", + desc: "Whether or not the original message should be edited when a news message is updated.", + val: true + } + } + }, + serverboard: { + description: "Configure your server's appearance on the serverboard.", + settings: { + shown: { + type: "BOOL", + desc: "Whether or not the server should be shown on the serverboard.", + val: false + } + } + }, + welcome: { + description: "Change how Sokora welcomes your new users.", + settings: { + join_text: { + type: "TEXT", + desc: "Text sent when a user joins. (name) - username, (count) - member count, (servername) - server name.", + val: "Welcome to (servername), (name)! Interestingly, you just helped us reach (count) members. Have a nice day!" + }, + leave_text: { + type: "TEXT", + desc: "Text sent when a user leaves. (name) - username, (count) - member count, (servername) - server name.", + val: "(name) has left the server! 😥" + }, + channel: { type: "TEXT", desc: "ID of the channel where welcome messages are sent." }, + join_dm: { + type: "BOOL", + desc: "Whether or not the bot should send a custom DM message to the user upon joining.", + val: false + }, + dm_text: { + type: "TEXT", + desc: "Text sent in the user's DM when they join the server. Same syntax as join_text.", + val: "Welcome to (servername), (name)! Interestingly, you just helped us reach (count) members. Have a nice day!" + } + } + }, + easter: { + description: "Enable/disable easter eggs.", + settings: { + enabled: { + type: "BOOL", + desc: "Whether or not the bot should reply to certain messages with 'easter egg' messages.", + val: false + } + } + } +}; + +export const settingsKeys = Object.keys(settingsDefinition) as (keyof typeof settingsDefinition)[]; +const database = getDatabase(tableDefinition); +const getQuery = database.query("SELECT * FROM settings WHERE guildID = $1 AND key = $2;"); +const listPublicQuery = database.query( + "SELECT * FROM settings WHERE key = 'serverboard.shown' AND value = '1';" +); +const deleteQuery = database.query("DELETE FROM settings WHERE guildID = $1 AND key = $2;"); +const insertQuery = database.query( + "INSERT INTO settings (guildID, key, value) VALUES (?1, ?2, ?3);" +); + +export function getSetting< + K extends keyof typeof settingsDefinition, + S extends keyof (typeof settingsDefinition)[K]["settings"] +>( + guildID: string, + key: K, + setting: S +): SqlType<(typeof settingsDefinition)[K]["settings"][S]["type"]> | null { + let res = getQuery.all(JSON.stringify(guildID), key + "." + setting) as TypeOfDefinition< + typeof tableDefinition + >[]; + const set = settingsDefinition[key].settings[setting]; + + if (!res.length) { + if (set.type == "LIST") return null; + return set.val; + } + + switch (set.type) { + case "TEXT": + return res[0].value as SqlType; + case "BOOL": + return (res[0].value === "1" ? true : false) as SqlType; + case "INTEGER": + return parseInt(res[0].value) as SqlType; + case "LIST": + return kominator(res[0].value) as SqlType; + default: + return "WIP"; // as TypeOfKey; + } +} + +export function setSetting( + guildID: string, + key: K, + setting: string, + value: string // TypeOfKey +) { + const doInsert = getSetting(guildID, key, setting) == null; + if (!doInsert) deleteQuery.all(JSON.stringify(guildID), key + "." + setting); + insertQuery.run(JSON.stringify(guildID), `${key}.${setting}`, value); +} + +export function listPublicServers() { + return (listPublicQuery.all() as TypeOfDefinition[]).map(entry => + JSON.parse(entry.guildID) + ); +} + +// Utility type +type TypeOfKey = SqlType< + (typeof settingsDefinition)[K]["settings"][S]["type"] +>; diff --git a/src/utils/database/types.ts b/src/utils/database/types.ts new file mode 100644 index 0000000..fb02252 --- /dev/null +++ b/src/utils/database/types.ts @@ -0,0 +1,18 @@ +export type FieldData = "TEXT" | "INTEGER" | "BOOL" | "TIMESTAMP" | "LIST"; + +export type TableDefinition = { + name: string; + definition: Record; +}; + +export type SqlType = { + BOOL: boolean; + INTEGER: number; + TEXT: string; + TIMESTAMP: Date; + LIST: any[]; +}[T]; + +export type TypeOfDefinition = { + [K in keyof T["definition"]]: SqlType; +}; diff --git a/src/utils/embeds/errorEmbed.ts b/src/utils/embeds/errorEmbed.ts index f52808d..ee3e2e6 100644 --- a/src/utils/embeds/errorEmbed.ts +++ b/src/utils/embeds/errorEmbed.ts @@ -1,15 +1,26 @@ +import { EmbedBuilder, type ChatInputCommandInteraction, type ButtonInteraction } from "discord.js"; +import { genColor } from "../colorGen"; + /** * Sends the embed containing an error. - * @param description Description of the error. + * @param interaction The interaction (slash command). + * @param title The error. + * @param reason The reason of the error. * @returns Embed with the error description. */ -import { EmbedBuilder } from "discord.js"; -import { genColor } from "../colorGen.js"; - -export default function errorEmbed(description: string) { - return new EmbedBuilder() - .setTitle("❌ • Error!") - .setDescription(description) +export async function errorEmbed( + interaction: ChatInputCommandInteraction | ButtonInteraction, + title: string, + reason?: string +) { + const content = [`**${title}**`]; + if (reason != undefined) content.push(reason); + const embed = new EmbedBuilder() + .setAuthor({ name: "Something went wrong!" }) + .setDescription(content.join("\n")) .setColor(genColor(0)); + + if (interaction.replied) return await interaction.followUp({ embeds: [embed], ephemeral: true }); + return await interaction.reply({ embeds: [embed], ephemeral: true }); } diff --git a/src/utils/embeds/modEmbed.ts b/src/utils/embeds/modEmbed.ts new file mode 100644 index 0000000..e7ada38 --- /dev/null +++ b/src/utils/embeds/modEmbed.ts @@ -0,0 +1,155 @@ +import { EmbedBuilder, type ChatInputCommandInteraction, type User } from "discord.js"; +import ms from "ms"; +import { genColor } from "../colorGen"; +import { addModeration, type modType } from "../database/moderation"; +import { logChannel } from "../logChannel"; +import { errorEmbed } from "./errorEmbed"; + +type Options = { + interaction: ChatInputCommandInteraction; + user: User; + action: string; + duration?: string | null; + dm?: boolean; + dbAction?: modType; + expiresAt?: number; +}; + +type ErrorOptions = { + allErrors: boolean; + botError: boolean; + ownerError?: boolean; + outsideError?: boolean; +}; + +export async function errorCheck( + permission: bigint, + options: Options, + errorOptions: ErrorOptions, + permissionAction: string +) { + const { interaction, user, action } = options; + const { allErrors, botError, ownerError, outsideError } = errorOptions; + const guild = interaction.guild!; + const members = guild.members.cache!; + const member = members.get(interaction.user.id)!; + const client = members.get(interaction.client.user.id)!; + const target = members.get(user.id)!; + const name = user.displayName; + + if (botError) + if (!client.permissions.has(permission)) + return await errorEmbed( + interaction, + "The bot can't execute this command.", + `The bot is missing the **${permissionAction}** permission.` + ); + + if (!member.permissions.has(permission)) + return await errorEmbed( + interaction, + "You can't execute this command.", + `You're missing the **${permissionAction} Members** permission.` + ); + + if (!allErrors) return; + if (!target) return; + if (target == member) + return await errorEmbed(interaction, `You can't ${action.toLowerCase()} yourself.`); + + if (target.id == interaction.client.user.id) + return await errorEmbed(interaction, `You can't ${action.toLowerCase()} Sokora.`); + + if (!target.manageable) + return await errorEmbed( + interaction, + `You can't ${action.toLowerCase()} ${name}.`, + "The member has a higher role position than Sokora." + ); + + if (member.roles.highest.position < target.roles.highest.position) + return await errorEmbed( + interaction, + `You can't ${action.toLowerCase()} ${name}.`, + "The member has a higher role position than you." + ); + + if (ownerError) { + if (target.id == guild.ownerId) + return await errorEmbed( + interaction, + `You can't ${action.toLowerCase()} ${name}.`, + "The member owns the server." + ); + } + + if (outsideError) { + if ( + !(await guild.members + .fetch(user.id) + .then(() => true) + .catch(() => false)) + ) + return await errorEmbed( + interaction, + `You can't ${action.toLowerCase()} ${name}.`, + "This user isn't in this server." + ); + } +} + +export async function modEmbed( + options: Options, + reason?: string | null, + showModerator: boolean = false +) { + const { interaction, user, action, duration, dm, dbAction, expiresAt } = options; + const guild = interaction.guild!; + const name = user.displayName; + const generalValues = [`**Moderator**: ${interaction.user.displayName}`]; + let author = `• ${action} ${name}`; + reason ? generalValues.push(`**Reason**: ${reason}`) : generalValues.push("*No reason provided*"); + if (duration) generalValues.push(`**Duration**: ${ms(ms(duration), { long: true })}`); + if (dbAction) { + try { + const id = addModeration( + guild.id, + user.id, + dbAction, + guild.members.cache.get(interaction.user.id)?.id!, + reason ?? undefined, + expiresAt ?? undefined + ); + author = author.concat(` • #${id}`); + } catch (error) { + console.error(error); + } + } + + const embed = new EmbedBuilder() + .setAuthor({ name: author, iconURL: user.displayAvatarURL() }) + .setDescription(generalValues.join("\n")) + .setFooter({ text: `User ID: ${user.id}` }) + .setColor(genColor(100)); + + await logChannel(guild, embed); + if (interaction.replied) await interaction.followUp({ embeds: [embed] }); + else await interaction.reply({ embeds: [embed] }); + + if (!dm) return; + const dmChannel = await user.createDM().catch(() => null); + if (!dmChannel || !guild.members.cache.get(user.id) || user.bot) return; + await dmChannel + .send({ + embeds: [ + embed + .setAuthor({ + name: `• You got ${action.toLowerCase()}.`, + iconURL: user.displayAvatarURL() + }) + .setDescription(generalValues.slice(+!showModerator, generalValues.length).join("\n")) + .setColor(genColor(0)) + ] + }) + .catch(() => null); +} diff --git a/src/utils/embeds/serverEmbed.ts b/src/utils/embeds/serverEmbed.ts index 43fefed..fd0b716 100644 --- a/src/utils/embeds/serverEmbed.ts +++ b/src/utils/embeds/serverEmbed.ts @@ -4,130 +4,98 @@ * @returns Embed that contains the guild info. */ -import { ColorResolvable, EmbedBuilder, type Guild } from "discord.js"; -import { genColor, genRGBColor } from "../colorGen.js"; -import Vibrant from "node-vibrant"; -import sharp from "sharp"; -import database, { getServerboardTable } from "../database.js"; +import { EmbedBuilder, type Guild } from "discord.js"; +import { genColor } from "../colorGen"; +import { imageColor } from "../imageColor"; type Options = { - guild: Guild - roles?: boolean - showInvite?: boolean - page?: number - pages?: number - showSubs?: boolean - subs?: number + guild: Guild; + roles?: boolean; + page?: number; + pages?: number; }; -export default async function serverEmbed(options: Options) { - const db = await database(); - - const page = options.page; - const pages = options.pages; - - // Retrieve guild information - const guild = options.guild; +export async function serverEmbed(options: Options) { + const { page, pages, guild } = options; const { premiumTier: boostTier, premiumSubscriptionCount: boostCount } = guild; - const iconURL = guild.iconURL(); - - // Getting the invite table - const showInvite = options.showInvite; - const serverbTable = await getServerboardTable(db); - const invite = await serverbTable?.get(`${guild.id}.invite`).then( - async (invite) => invite ? String(invite) : null - ).catch(() => null); - - // Getting different types of members const members = guild.members.cache; const boosters = members.filter(member => member.premiumSince); - const onlineMembers = members.filter(member => ["online", "dnd", "idle"].includes(member.presence?.status)).size; + const onlineMembers = members.filter(member => + ["online", "dnd", "idle"].includes(member.presence?.status!) + ).size; const bots = members.filter(member => member.user.bot); - - // Formatting numbers to the American comma format - const formattedMemberCount = guild.memberCount?.toLocaleString("en-US"); - const formattedOnlineMembers = onlineMembers?.toLocaleString("en-US"); const formattedUserCount = (guild.memberCount - bots.size)?.toLocaleString("en-US"); - const formattedBotCount = bots.size?.toLocaleString("en-US"); + const icon = guild.iconURL()!; - // Sorting the roles const roles = guild.roles.cache; - const rolesSorted = [...roles].sort((role1, role2) => role2[1].position - role1[1].position); - rolesSorted.pop(); + const sortedRoles = [...roles].sort((role1, role2) => role2[1].position - role1[1].position); + sortedRoles.pop(); + const rolesLength = sortedRoles.length; - // Organising the channel sizes const channels = guild.channels.cache; const channelSizes = { - text: channels.filter(channel => channel.type === 0 || channel.type === 15 || channel.type === 5).size, - voice: channels.filter(channel => channel.type === 2 || channel.type === 13).size, - categories: channels.filter(channel => channel.type === 4).size - } + text: channels.filter(channel => channel.type == 0 || channel.type == 15 || channel.type == 5) + .size, + voice: channels.filter(channel => channel.type == 2 || channel.type == 13).size, + categories: channels.filter(channel => channel.type == 4).size + }; - // Create the embed const generalValues = [ - `**Owner**: <@${guild.ownerId}>`, - `**Created on** `, - ] - if (options.showSubs) generalValues.push(`**Subscribers**: ${options.subs}`); - if (showInvite && invite) generalValues.push(`**Invite link**: ${invite}`); + `Owned by **${(await guild.fetchOwner()).user.displayName}**`, + `Created on ****` + ]; const embed = new EmbedBuilder() .setAuthor({ - name: `${pages ? `#${page} • ` : ""}${guild.name}`, - url: showInvite && invite ? invite : null, - iconURL: iconURL + name: `${pages ? `#${page} • ` : icon ? "• " : ""}${guild.name}`, + iconURL: icon }) .setDescription(guild.description ? guild.description : null) - .setFields({ - name: "📃 • General", - value: generalValues.join("\n") - }) - .setFooter({ text: `Server ID: ${guild.id}${pages ? ` • Page ${page}/${pages}` : ""}` }) - .setThumbnail(iconURL) - .setColor(genColor(200)); - - // Set the embed color - try { - const imageBuffer = await (await fetch(iconURL)).arrayBuffer(); // Stream the buffer - const image = sharp(imageBuffer).toFormat("jpg"); - const { r, g, b } = (await new Vibrant(await image.toBuffer()).getPalette()).Vibrant; // Get the most vibrant color - embed.setColor(genRGBColor(r, g, b) as ColorResolvable); - } catch { } - - // Adding the fields - if (options.roles) embed.addFields({ - name: `🎭 • ${roles.size - 1} ${roles.size === 1 ? "role" : "roles"}`, - value: roles.size === 1 - ? "*None*" - : `${rolesSorted.slice(0, 5).map(role => `<@&${role[0]}>`).join(", ")}${roles.size > 5 ? ` **and ${roles.size - 5} more**` : ""}` - }) + .setFields({ name: "📃 • General", value: generalValues.join("\n") }) + .setFooter({ text: `${pages ? `Page ${page}/${pages}\n` : ""}Server ID: ${guild.id}` }) + .setThumbnail(icon) + .setColor((await imageColor(icon)) ?? genColor(200)); + + if (options.roles) + embed.addFields({ + name: `🎭 • ${roles.size - 1} ${roles.size == 1 ? "role" : "roles"}`, + value: + roles.size == 1 + ? "*None*" + : `${sortedRoles + .slice(0, 5) + .map(role => `<@&${role[0]}>`) + .join(", ")}${rolesLength > 5 ? ` and **${rolesLength - 5}** more` : ""}` + }); embed.addFields( { - name: `👥 • ${formattedMemberCount} members`, + name: `👥 • ${guild.memberCount?.toLocaleString("en-US")} members`, value: [ - `${formattedUserCount} users • ${formattedBotCount} bots`, - `${formattedOnlineMembers} online` + `**${formattedUserCount}** users • **${bots.size?.toLocaleString("en-US")}** bots`, + `**${onlineMembers?.toLocaleString("en-US")}** online` ].join("\n"), inline: true }, { name: `🗨️ • ${channelSizes.text + channelSizes.voice} channels`, value: [ - `${channelSizes.text} text • ${channelSizes.voice} voice`, - `${channelSizes.categories} categories` + `**${channelSizes.text}** text • **${channelSizes.voice}** voice`, + `**${channelSizes.categories}** categories` ].join("\n"), inline: true }, { - name: `🌟 • ${boostCount}${boostTier === 0 ? "/2" : boostTier === 1 ? "/7" : boostTier === 2 ? "/14" : ""} boosts`, + name: `🌟 • ${!boostTier ? "No level" : `Level ${boostTier}`}`, value: [ - boostTier == 0 ? "No boosts" : `Level ${boostTier}`, - `${boosters.size} ${boosters.size === 1 ? "booster" : "boosters"}` + `**${boostCount}**${ + !boostTier ? "/2" : boostTier == 1 ? "/7" : boostTier == 2 ? "/14" : "" + } boosts`, + `**${boosters.size}** ${boosters.size == 1 ? "booster" : "boosters"}` ].join("\n"), inline: true } - ) + ); return embed; } diff --git a/src/utils/imageColor.ts b/src/utils/imageColor.ts new file mode 100644 index 0000000..22a162c --- /dev/null +++ b/src/utils/imageColor.ts @@ -0,0 +1,22 @@ +/** + * Outputs the most vibrant color from the image. + * @param guild Guild image. + * @param member Member image. + * @returns The color in HEX. + */ + +import type { ColorResolvable } from "discord.js"; +import Vibrant from "node-vibrant"; +import sharp from "sharp"; +import { genRGBColor } from "./colorGen"; + +export async function imageColor(guildURL?: string, memberURL?: string) { + if (!guildURL || !memberURL) return; + + const imageBuffer = await (await fetch(guildURL ?? memberURL)).arrayBuffer(); + const { r, g, b } = ( + await new Vibrant(await sharp(imageBuffer).toFormat("jpg").toBuffer()).getPalette() + ).Vibrant!; + + return genRGBColor(Math.round(r), Math.round(g), Math.round(b)) as ColorResolvable; +} diff --git a/src/utils/kominator.ts b/src/utils/kominator.ts new file mode 100644 index 0000000..b1c8298 --- /dev/null +++ b/src/utils/kominator.ts @@ -0,0 +1,9 @@ +/** + * Splits a string using commas. + * @param string String to split. + * @returns An array of strings from the original string. + */ + +export function kominator(string: string): string[] { + return string.split(",").map(str => str.replace('"', "").trim()); +} diff --git a/src/utils/logChannel.ts b/src/utils/logChannel.ts new file mode 100644 index 0000000..a733617 --- /dev/null +++ b/src/utils/logChannel.ts @@ -0,0 +1,38 @@ +/** + * Sends a message in the log channel. (if there is one set) + * @param guild The guild where the log channel is located. + * @param embed Embed of the log. + * @returns Log message. + */ + +import { + ChannelType, + type Channel, + type EmbedBuilder, + type Guild, + type TextChannel +} from "discord.js"; +import { getSetting } from "./database/settings"; + +export async function logChannel(guild: Guild, embed: EmbedBuilder) { + const logChannel = getSetting(guild.id, "moderation", "channel"); + if (!logChannel) return; + + const channel = await guild.channels.cache + .get(`${logChannel}`) + ?.fetch() + .then((channel: Channel) => { + if ( + channel.type != ChannelType.GuildText && + ChannelType.PublicThread && + ChannelType.PrivateThread && + ChannelType.GuildVoice + ) + return null; + + return channel as TextChannel; + }) + .catch(() => null); + + if (channel) return await channel.send({ embeds: [embed] }); +} diff --git a/src/utils/multiReact.ts b/src/utils/multiReact.ts index 03d5be5..dfbf46e 100644 --- a/src/utils/multiReact.ts +++ b/src/utils/multiReact.ts @@ -1,12 +1,17 @@ +/** + * Reacts to a message with multiple emojis. + * @param message Message to react to. + * @param emojis Emojis that will be used to react. + */ + import type { Message } from "discord.js"; -export default async function multiReact(message: Message, ...reactions) { - for (const i of reactions) { - if (typeof i === "object") { +export async function multiReact(message: Message, ...emojis: string[]) { + for (const i of emojis) { + if (typeof i == "object") { await message.react(i); continue; } - - for (const reaction of i) if (reaction !== " ") await message.react(reaction); + for (const reaction of i) if (reaction != " ") await message.react(reaction); } } diff --git a/src/utils/quickSort.ts b/src/utils/quickSort.ts index 9aa40b2..38beb94 100644 --- a/src/utils/quickSort.ts +++ b/src/utils/quickSort.ts @@ -1,18 +1,20 @@ type Corresponding = [...any[]] | null; -// Swap the values -function swap(sortItems: number[], corresponding: Corresponding, leftIndex: number, rightIndex: number) { +function swap( + sortItems: number[], + corresponding: Corresponding, + leftIndex: number, + rightIndex: number +) { let temp = sortItems[leftIndex]; sortItems[leftIndex] = sortItems[rightIndex]; sortItems[rightIndex] = temp; - // Swap the corresponding values if (!corresponding) return; for (let i = 0; i < corresponding.length; i++) { let subArray = corresponding[i]; if (Array.isArray(subArray)) { - // Swap the corresponding sub-array values let tempValue = subArray[leftIndex]; subArray[leftIndex] = subArray[rightIndex]; subArray[rightIndex] = tempValue; @@ -20,56 +22,40 @@ function swap(sortItems: number[], corresponding: Corresponding, leftIndex: numb } } -// Partition the array -function partition(sortItems: number[], corresponding: Corresponding, leftIndex: number, rightIndex: number): number { - let pivot: number = sortItems[Math.floor((rightIndex + leftIndex) / 2)], // middle element - leftPointer: number = leftIndex, // left pointer - rightPointer: number = rightIndex; // right pointer +/** + * Sorts an array of items and returns the sorted items and corresponding items. + * @param sortItems The items to sort. + * @param corresponding The corresponding items to sort. + * @param leftIndex The left index of the sortItems array. + * @param rightIndex The right index of the sortItems array. + * @returns The sorted items and corresponding items. + */ +export function quickSort( + sortItems: number[], + corresponding: Corresponding, + leftIndex: number, + rightIndex: number +): [number[], Corresponding] { + let pivot = sortItems[Math.floor((rightIndex + leftIndex) / 2)]; + let leftPointer = leftIndex; + let rightPointer = rightIndex; while (leftPointer <= rightPointer) { while (sortItems[leftPointer] < pivot) leftPointer++; while (sortItems[rightPointer] > pivot) rightPointer--; if (leftPointer <= rightPointer) { - swap(sortItems, corresponding, leftPointer, rightPointer); // swapping two elements + swap(sortItems, corresponding, leftPointer, rightPointer); leftPointer++; rightPointer--; } } - return leftPointer; -} - -/** - * Sorts an array of items and returns the sorted items and corresponding items - * @param sortItems The items to sort - * @param corresponding The corresponding items to sort, but linked to the exact index of the sortItems array (input [[...], [...], ...]) (Optional) - * @param leftIndex The left index of the sortItems array - * @param rightIndex The right index of the sortItems array - * @returns The sorted items and corresponding items - */ -// Initialize the quicksort function -export default function quickSort( - sortItems: number[], - corresponding: Corresponding, - leftIndex: number, - rightIndex: number -): [number[], Corresponding] { - let index: number; - if (sortItems.length > 1) { - index = partition(sortItems, corresponding, leftIndex, rightIndex); // index returned from partition - - if (leftIndex < index - 1) { - // more elements on the left side of the pivot - quickSort(sortItems, corresponding, leftIndex, index - 1); - } - - if (index < rightIndex) { - // more elements on the right side of the pivot - quickSort(sortItems, corresponding, index, rightIndex); - } + if (leftIndex < leftPointer - 1) + quickSort(sortItems, corresponding, leftIndex, leftPointer - 1); + if (leftPointer < rightIndex) quickSort(sortItems, corresponding, leftPointer, rightIndex); } - return [sortItems, corresponding]; // Return both sorted items and corresponding array + return [sortItems, corresponding]; } diff --git a/src/utils/randomise.ts b/src/utils/randomise.ts index a1c99ad..b526712 100644 --- a/src/utils/randomise.ts +++ b/src/utils/randomise.ts @@ -4,6 +4,6 @@ * @returns Randomised value from within the array. */ -export default function randomise(array: any[]) { +export function randomise(array: any[]) { return array[Math.floor(Math.random() * array.length)]; } diff --git a/src/utils/replace.ts b/src/utils/replace.ts new file mode 100644 index 0000000..2d0f6a6 --- /dev/null +++ b/src/utils/replace.ts @@ -0,0 +1,10 @@ +import type { GuildMember, EmbedBuilder } from "discord.js"; + +export function replace(member: GuildMember, text: string, embed: EmbedBuilder) { + const user = member.user; + const guild = member.guild; + if (text?.includes("(name)")) text = text.replaceAll("(name)", user.displayName); + if (text?.includes("(count)")) text = text.replaceAll("(count)", `${guild.memberCount}`); + if (text?.includes("(servername)")) text = text.replaceAll("(servername)", `${guild.name}`); + embed.setDescription(text); +} diff --git a/src/utils/sendChannelNews.ts b/src/utils/sendChannelNews.ts index 9bc231e..b45eb2c 100644 --- a/src/utils/sendChannelNews.ts +++ b/src/utils/sendChannelNews.ts @@ -1,53 +1,52 @@ +/** + * Sends news to a channel. + * @param guild Guild where the channel is in. + * @param id ID of the news. + * @param interaction Command nteraction. + * @param title Title of the news. + * @param body Content of the news. + * @returns News message in a channel. + */ + import { - EmbedBuilder, Guild, Role, - TextChannel + EmbedBuilder, + type ChatInputCommandInteraction, + type Guild, + type Role, + type TextChannel } from "discord.js"; -import database, { getNewsTable } from "./database.js"; -import { genColor } from "./colorGen.js"; - -export type News = { - title: string - body: string - imageURL: string - author: string - authorPfp: string - createdAt: string - updatedAt: string - messageId?: string -} - -export default async function sendChannelNews(guild: Guild, news: News, id: string) { - const db = await database(); - const newsTable = await getNewsTable(db); +import { genColor } from "./colorGen"; +import { get, updateNews } from "./database/news"; +import { getSetting } from "./database/settings"; - const subscribedChannel = await newsTable.get(`${guild.id}.channel`).then( - (channel) => channel as { channelId: string | null, roleId: string | null } - ).catch(() => { - return { - channelId: null as string | null, - roleId: null as string | null - }; - }); - - if (!subscribedChannel) return; - const channel = subscribedChannel.channelId; - const channelToSend = guild.channels.cache.get(channel) as TextChannel; - if (!channelToSend) return; - - const role = subscribedChannel.roleId; - let roleToSend: Role | null; +export async function sendChannelNews( + guild: Guild, + id: string, + interaction: ChatInputCommandInteraction, + title?: string, + body?: string +) { + const news = get(id)!; + const role = getSetting(guild.id, "news", "role_id") as string; + let roleToSend: Role | undefined; if (role) roleToSend = guild.roles.cache.get(role); - const newsEmbed = new EmbedBuilder() - .setAuthor({ name: news.author, iconURL: news.authorPfp ?? null }) - .setTitle(news.title) - .setDescription(news.body) - .setImage(news.imageURL || null) - .setTimestamp(parseInt(news.updatedAt)) - .setFooter({ text: `Latest news from ${guild.name}` }) + const embed = new EmbedBuilder() + .setAuthor({ name: `• ${news.author}`, iconURL: news.authorPFP }) + .setTitle(title ?? news.title) + .setDescription(body ?? news.body) + .setTimestamp(parseInt(news.updatedAt.toString()) ?? null) + .setFooter({ text: `Latest news from ${guild.name}\nID: ${news.id}` }) .setColor(genColor(200)); - const message = await channelToSend.send({ embeds: [newsEmbed], content: roleToSend ? `<@&${roleToSend.id}>` : null }); - await newsTable.set(`${guild.id}.news.${id}.messageId`, message.id); - return; -} \ No newline at end of file + return ( + guild.channels.cache.get( + (getSetting(guild.id, "news", "channel_id") as string) ?? interaction.channel?.id + ) as TextChannel + ) + .send({ + embeds: [embed], + content: roleToSend ? `<@&${roleToSend.id}>` : undefined + }) + .then(message => updateNews(id, undefined, undefined, message.id)); +} diff --git a/src/utils/sendSubscribedNews.ts b/src/utils/sendSubscribedNews.ts deleted file mode 100644 index 1ce906f..0000000 --- a/src/utils/sendSubscribedNews.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { DMChannel, EmbedBuilder, Guild } from "discord.js"; -import database, { getNewsTable } from "./database.js"; -import { genColor } from "./colorGen.js"; - -export type News = { - title: string - body: string - imageURL: string - author: string - authorPfp: string - createdAt: string - updatedAt: string - messageId?: string -} - -export default async function sendSubscribedNews(guild: Guild, news: News) { - const db = await database(); - const newsTable = await getNewsTable(db); - - const subscriptions = await newsTable.get(`${guild.id}.subscriptions`).then( - (subscriptions) => subscriptions as string[] ?? [] as string[] - ).catch(() => [] as string[]); - const members = await guild.members.fetch(); - const subscribed = members.filter((member) => subscriptions.includes(member.id)); - const memberDMs = (await Promise.all(subscribed.map((member) => member.createDM().catch(() => null)))) as DMChannel[] | null;; - const memberDMsOpen = memberDMs.filter((dm) => dm !== null); - - const newsEmbed = new EmbedBuilder() - .setAuthor({ name: news.author, iconURL: news.authorPfp ?? null }) - .setTitle(news.title) - .setDescription(news.body) - .setImage(news.imageURL || null) - .setTimestamp(parseInt(news.updatedAt)) - .setFooter({ text: `Latest news from ${guild.name}` }) - .setColor(genColor(200)); - - await Promise.all(memberDMsOpen.map((dm) => dm.send({ embeds: [newsEmbed] }).catch(() => null))); - return; -} diff --git a/src/utils/types.ts b/src/utils/types.ts new file mode 100644 index 0000000..9e8daf0 --- /dev/null +++ b/src/utils/types.ts @@ -0,0 +1,3 @@ +import { ClientEvents } from "discord.js"; + +export type Event = (...args: ClientEvents[T][0][]) => any; diff --git a/src/utils/unbanScheduler.ts b/src/utils/unbanScheduler.ts new file mode 100644 index 0000000..599fccf --- /dev/null +++ b/src/utils/unbanScheduler.ts @@ -0,0 +1,57 @@ +import { Client, EmbedBuilder } from "discord.js"; +import { genColor } from "./colorGen"; +import { getPendingBans, removeModeration } from "./database/moderation"; +import { logChannel } from "./logChannel"; + +export function scheduleUnban( + client: Client, + guildID: string, + userID: string, + modID: string, + delay: number +) { + const scheduledUnbans = new Map(); + const key = `${guildID}-${userID}`; + if (scheduledUnbans.has(key)) clearTimeout(scheduledUnbans.get(key)!); + + const timeout = setTimeout(async () => { + try { + const guild = await client.guilds.fetch(guildID); + const user = guild.bans.cache.get(userID)?.user!; + const moderator = guild.members.cache.get(modID)!; + const embed = new EmbedBuilder() + .setAuthor({ name: `• Unbanned ${user.displayName}`, iconURL: user.displayAvatarURL() }) + .setDescription( + [`**Moderator**: ${moderator.displayName}`, "*Temporary ban has expired*"].join("\n") + ) + .setFooter({ text: `User ID: ${user.id}` }) + .setColor(genColor(100)); + + await logChannel(guild, embed); + await guild.members.unban(userID, "Temporary ban has expired"); + removeModeration(guildID, userID); + scheduledUnbans.delete(key); + } catch (error) { + console.error(`Failed to unban user ${userID} in guild ${guildID}:`, error); + } + }, delay); + + return scheduledUnbans.set(key, timeout); +} + +export function rescheduleUnbans(client: Client) { + const now = Date.now(); + const pendingBans = getPendingBans(now); + + for (const ban of pendingBans) { + if (!ban.expiresAt) continue; + if (typeof ban.expiresAt !== "number" || isNaN(ban.expiresAt)) { + console.error(`Invalid expiresAt value for ban: ${ban.expiresAt}`); + continue; + } + + const delay = ban.expiresAt - now; + if (delay > 0) scheduleUnban(client, ban.guild, ban.user, ban.moderator, delay); + else removeModeration(ban.guild, ban.id); + } +} diff --git a/src/utils/validateURL.ts b/src/utils/validateURL.ts deleted file mode 100644 index 7b6857e..0000000 --- a/src/utils/validateURL.ts +++ /dev/null @@ -1,4 +0,0 @@ -export default function validateURL(url) { - if (/^(http(s):\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g.test(url)) return true; - return false; -} diff --git a/static/banner.png b/static/banner.png new file mode 100644 index 0000000..a4dfb93 Binary files /dev/null and b/static/banner.png differ diff --git a/static/banner.svg b/static/banner.svg deleted file mode 100644 index faa565e..0000000 --- a/static/banner.svg +++ /dev/null @@ -1,60 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/tsconfig.json b/tsconfig.json index e4ce5f9..1a7a764 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,20 @@ { "compilerOptions": { - "target": "ESNext", - "module": "NodeNext", - "moduleResolution": "NodeNext", - "noImplicitAny": false, - "removeComments": true, - "preserveConstEnums": true, - "forceConsistentCasingInFileNames": true, - "esModuleInterop": true, + "lib": ["ESNext"], + "module": "esnext", + "target": "esnext", + "moduleResolution": "Node", + "moduleDetection": "force", + "allowImportingTsExtensions": true, + "resolveJsonModule": true, + "noEmit": true, + "composite": true, + "strict": true, + "downlevelIteration": true, "skipLibCheck": true, "allowSyntheticDefaultImports": true, - "noFallthroughCasesInSwitch": true, - "resolveJsonModule": true, - "isolatedModules": true, - "strict": false, + "forceConsistentCasingInFileNames": true, + "allowJs": true, "types": ["bun-types"] - }, - "exclude": ["node_modules"], -} + } +} \ No newline at end of file