diff --git a/.gitignore b/.gitignore index 00ad2e5..84f2422 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ node_modules/ .env .DS_Store -data.db \ No newline at end of file +data.db +bun.lockb diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a6f3644..fa5e502 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -18,7 +18,7 @@ - Run `bun run setup` and our cli tool will install dependencies and write .env for you ### Running -- Run `bun start`. +- Run `bun dev`. 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/bun.lockb b/bun.lockb index 895266d..832104c 100644 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 5207f56..02bda1d 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "The Sokora team", "The GitHub contributors" ], - "version": "0.1.0", + "version": "0.2.0", "main": "./src/index.ts", "type": "module", "scripts": { @@ -21,7 +21,7 @@ }, "devDependencies": { "@types/ms": "^0.7.34", - "bun-types": "^1.1.34", - "typescript": "^5.6.3" + "bun-types": "^1.1.41", + "typescript": "^5.7.2" } } diff --git a/src/bot.ts b/src/bot.ts index bd8e19b..0eeaf5d 100644 --- a/src/bot.ts +++ b/src/bot.ts @@ -1,6 +1,7 @@ import { ActivityType, Client } from "discord.js"; -import { Commands } from "./handlers/commands"; -import { Events } from "./handlers/events"; +import { registerCommands } from "./handlers/commands"; +import { loadEvents } from "./handlers/events"; +import { leavePlease } from "./utils/leavePlease"; import { rescheduleUnbans } from "./utils/unbanScheduler"; const client = new Client({ @@ -12,15 +13,18 @@ const client = new Client({ "GuildMembers", "GuildMessages", "GuildEmojisAndStickers", - "GuildPresences", "GuildBans", "MessageContent" ] }); client.on("ready", async () => { - await new Events(client).loadEvents(); - await new Commands(client).registerCommands(); + const guilds = client.guilds.cache; + for (const id of guilds.keys()) + await leavePlease(guilds.get(id)!, await guilds.get(id)?.fetchOwner()!, "Not like that."); + + await loadEvents(client); + await registerCommands(client); console.log("ちーっす!"); rescheduleUnbans(client); }); diff --git a/src/commands/About.ts b/src/commands/About.ts index e40f5e2..34e70a6 100644 --- a/src/commands/About.ts +++ b/src/commands/About.ts @@ -6,9 +6,11 @@ import { SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { version } from "../../package.json"; import { genColor } from "../utils/colorGen"; import { imageColor } from "../utils/imageColor"; -import { randomise } from "../utils/randomise"; +import { pluralOrNot } from "../utils/pluralOrNot"; +import { replace } from "../utils/replace"; export default class About { data: SlashCommandBuilder; @@ -25,8 +27,6 @@ export default class About { 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 }) @@ -37,35 +37,30 @@ export default class About { { name: "📃 • General", value: [ - "Version **0.1.1**, *Kaishi*", - `**${members}** members • **${guilds.size}** guild${guilds.size == 1 ? "" : "s"} ${ - !shards ? "" : `• **${shards}** shard${shards == 1 ? "" : "s"}` + `Version **${version}**, *Kaishi*`, + `**${members}** ${pluralOrNot("member", members)} • **${guilds.size}** ${pluralOrNot("guild", guilds.size)} ${ + !shards ? "" : `• **${shards}** ${pluralOrNot("shard", shards)}` }` ].join("\n") }, { - name: "🌌 • Entities involved", + name: "🔗 • Links", value: [ - "**Founder**: Goos", - "**Developers**: Dimkauzh, Froxcey, Golem64, Koslz, MQuery, Nikkerudon, Spectrum, ThatBOI", - "**Designers**: ArtyH, ZakaHaceCosas, Pjanda", - "**Translator Lead**: ThatBOI", - "**Translators**: Dimkauzh, flojo, Golem64, GraczNet, Nikkerudon, ZakaHaceCosas, SaFire, TrulyBlue", - "**Testers**: Blaze, fishy, Trynera", - "And **YOU**, for using Sokora." + "[Discord](https://discord.gg/c6C25P4BuY) • [GitHub](https://www.github.com/SokoraDesu) • [YouTube](https://www.youtube.com/@SokoraDesu) • [Instagram](https://instagram.com/NebulaTheBot) • [Mastodon](https://mastodon.online/@NebulaTheBot@mastodon.social) • [Matrix](https://matrix.to/#/#sokora:matrix.org) • [Revolt](https://rvlt.gg/28TS9aXy)", + "Also, please read the [ToS](https://sokora.org/terms) and the [privacy policy](https://sokora.org/privacy)." ].join("\n") - }, - { - name: "🔗 • Links", - value: - "[GitHub](https://www.github.com/SokoraDesu) • [YouTube](https://www.youtube.com/@SokoraDesu) • [Instagram](https://instagram.com/NebulaTheBot) • [Mastodon](https://mastodon.online/@NebulaTheBot@mastodon.social) • [Revolt](https://rvlt.gg/28TS9aXy)" } ) - .setFooter({ text: `Made with ${randomise(emojis)} by the Sokora team` }) + .setFooter({ text: replace("(madeWith)") }) .setThumbnail(avatar) .setColor(user.hexAccentColor ?? (await imageColor(undefined, avatar)) ?? genColor(270)); const row = new ActionRowBuilder().addComponents( + new ButtonBuilder() + .setLabel("• Vote") + .setURL("https://top.gg/bot/873918300726394960/vote") + .setEmoji("🗳️") + .setStyle(ButtonStyle.Link), new ButtonBuilder() .setLabel("• Donate") .setURL("https://paypal.me/SokoraTheBot") diff --git a/src/commands/Changelog.ts b/src/commands/Changelog.ts new file mode 100644 index 0000000..e26a16e --- /dev/null +++ b/src/commands/Changelog.ts @@ -0,0 +1,65 @@ +import { EmbedBuilder, SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { version } from "../../package.json"; +import { genColor } from "../utils/colorGen"; +import { imageColor } from "../utils/imageColor"; +import { replace } from "../utils/replace"; + +export default class Changelog { + data: SlashCommandBuilder; + constructor() { + this.data = new SlashCommandBuilder() + .setName("changelog") + .setDescription("Shows the changelog of Sokora's most recent update."); + } + + async run(interaction: ChatInputCommandInteraction) { + const user = interaction.client.user; + const avatar = user.displayAvatarURL(); + const text = [ + ["## Added", "### Commands", "- /changelog", "- /credits", "- /moderation notes"].join("\n"), + [ + "## Changed", + "- The bot will remove levels when an admin changed the leveling difficulty", + "- /leaderboard shows 6 users per page instead of 5", + "- When you add the bot, it sends a message in the system channel", + "- Remade the message logs", + " - Edit logs will let you jump to the message that got edited" + ].join("\n"), + [ + "### /settings", + "- Autocompletes with channels/users/roles (you don't have to copy IDs now :tada:)", + "- In the embed it will show links to channels/users/roles instead of showing IDs" + ].join("\n"), + [ + "### /about", + "- Vote button added", + "- Removed credits and put them in a different command to reduce the height of the embed" + ].join("\n"), + [ + "## Fixed", + "### News", + "- Major issue related to the database, where the guild wasn't provided to ensure that news would be unique to every server, **thank you @Golem642!!!!**", + "- /news edit's modal errored when sending" + ].join("\n"), + [ + "### Moderation commands", + "- /moderation clear removed one more message than the user provided", + '- /moderation unban errored internally (it should send an error embed) when the user didn\'t have the "Ban Members" permission' + ].join("\n"), + [ + "## Typos", + '- "warn" mentions in /moderation warn are now "warning" to be more consistent', + "- Removed old markdown remnants from /moderation slowdown" + ].join("\n") + ].join("\n"); + + const embed = new EmbedBuilder() + .setAuthor({ name: `• Changelog for ${version}`, iconURL: avatar }) + .setDescription(text) + .setFooter({ text: replace("(madeWith)") }) + .setThumbnail(avatar) + .setColor(user.hexAccentColor ?? (await imageColor(undefined, avatar)) ?? genColor(270)); + + await interaction.reply({ embeds: [embed] }); + } +} diff --git a/src/commands/Credits.ts b/src/commands/Credits.ts new file mode 100644 index 0000000..c2688cb --- /dev/null +++ b/src/commands/Credits.ts @@ -0,0 +1,35 @@ +import { EmbedBuilder, SlashCommandBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { genColor } from "../utils/colorGen"; +import { imageColor } from "../utils/imageColor"; +import { replace } from "../utils/replace"; + +export default class Credits { + data: SlashCommandBuilder; + constructor() { + this.data = new SlashCommandBuilder() + .setName("credits") + .setDescription("Shows everyone who worked on Sokora."); + } + + async run(interaction: ChatInputCommandInteraction) { + const user = interaction.client.user; + const avatar = user.displayAvatarURL(); + const embed = new EmbedBuilder() + .setAuthor({ name: "• Entities involved", iconURL: avatar }) + .setDescription( + [ + "**Founder**: Goos", + "**Developers**: Dimkauzh, Froxcey, Golem64, Koslz, Littie, MQuery, Nikkerudon, Spectrum, ThatBOI", + "**Designers**: ArtyH, ZakaHaceCosas, Pjanda", + "**Translator Lead**: ThatBOI", + "**Translators**: Dimkauzh, flojo, Golem64, GraczNet, Nikkerudon, ZakaHaceCosas, SaFire, TrulyBlue", + "**Testers**: Blaze, fishy, Trynera" + ].join("\n") + ) + .setFooter({ text: replace("(madeWith)") }) + .setThumbnail(avatar) + .setColor(user.hexAccentColor ?? (await imageColor(undefined, avatar)) ?? genColor(270)); + + await interaction.reply({ embeds: [embed] }); + } +} diff --git a/src/commands/Leaderboard.ts b/src/commands/Leaderboard.ts index b07c8f3..b10e56d 100644 --- a/src/commands/Leaderboard.ts +++ b/src/commands/Leaderboard.ts @@ -38,12 +38,12 @@ export default class Leaderboard { else return b.xp - a.xp; }); - const totalPages = Math.ceil(leaderboardData.length / 5); + const totalPages = Math.ceil(leaderboardData.length / 6); 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 start = (page - 1) * 6; + const end = start + 6; const pageData = leaderboardData.slice(start, end); const embed = new EmbedBuilder() diff --git a/src/commands/Settings.ts b/src/commands/Settings.ts index 2654ded..1b2aa4a 100644 --- a/src/commands/Settings.ts +++ b/src/commands/Settings.ts @@ -30,7 +30,7 @@ export default class Settings { .setDescription(settingsDefinition[key].description); Object.keys(settingsDefinition[key].settings).forEach(sub => { - switch (settingsDefinition[key].settings[sub]["type"] as string) { + switch (settingsDefinition[key].settings[sub].type as string) { case "BOOL": subcommand.addBooleanOption(option => option @@ -47,6 +47,14 @@ export default class Settings { .setRequired(false) ); break; + case "CHANNEL": + subcommand.addChannelOption(option => + option + .setName(sub) + .setDescription(settingsDefinition[key].settings[sub]["desc"]) + .setRequired(false) + ); + break; case "USER": subcommand.addUserOption(option => option @@ -55,6 +63,14 @@ export default class Settings { .setRequired(false) ); break; + case "ROLE": + subcommand.addRoleOption(option => + option + .setName(sub) + .setDescription(settingsDefinition[key].settings[sub]["desc"]) + .setRequired(false) + ); + break; default: // Also includes "TEXT" subcommand.addStringOption(option => option @@ -71,11 +87,7 @@ export default class Settings { async run(interaction: ChatInputCommandInteraction) { const guild = interaction.guild!; - if ( - !guild.members.cache - ?.get(interaction.user.id) - ?.permissions.has(PermissionsBitField.Flags.Administrator) - ) + if (!guild.members.cache?.get(interaction.user.id)?.permissions.has("Administrator")) return await errorEmbed( interaction, "You can't execute this command.", @@ -84,11 +96,31 @@ export default class Settings { const key = interaction.options.getSubcommand() as keyof typeof settingsDefinition; const values = interaction.options.data[0].options!; + const settingsDef = settingsDefinition[key]; + const settingText = (name: string) => { + const setting = getSetting(guild.id, key, name)?.toString(); + let text; + switch (settingsDef.settings[name].type) { + case "CHANNEL": + text = setting ? `<#${setting}>` : "Not set"; + break; + case "USER": + text = setting ? `<@${setting}>` : "Not set"; + break; + case "ROLE": + text = setting ? `<@&${setting}>` : "Not set"; + break; + default: + text = setting || "Not set"; + break; + } + return text; + }; + if (!values.length) { - const settingsDef = settingsDefinition[key]; const field: string[] = []; Object.keys(settingsDef.settings).forEach(name => - field.push(`**${name}**: ${getSetting(guild.id, key, name)?.toString() || "Not set"}`) + field.push(`**${name}**: ${settingText(name)}`) ); const embed = new EmbedBuilder() @@ -101,16 +133,31 @@ export default class Settings { } const embed = new EmbedBuilder() - .setAuthor({ name: "Parameters changed" }) + .setAuthor({ name: "Parameters changed." }) .setColor(genColor(100)); - values.forEach(option => { + for (let i = 0; i < values.length; i++) { + const option = values[i]; + + if ( + option.type == 7 && + !guild.channels.cache + .get(option.value as string) + ?.permissionsFor(interaction.client.user) + ?.has("ViewChannel") + ) + return await errorEmbed( + interaction, + "Can't view this channel.", + "You can either give the **View Channel** permission for Sokora or use a channel from the dropdown menu." + ); + setSetting(guild.id, key, option.name, option.value as string); embed.addFields({ name: option.name, - value: option.value?.toString() || "Not set" + value: settingText(option.name.toString()!) }); - }); + } await interaction.reply({ embeds: [embed] }); } diff --git a/src/commands/User.ts b/src/commands/User.ts index ce5a787..9350700 100644 --- a/src/commands/User.ts +++ b/src/commands/User.ts @@ -13,6 +13,7 @@ import { getLevel } from "../utils/database/leveling"; import { getSetting } from "../utils/database/settings"; import { errorEmbed } from "../utils/embeds/errorEmbed"; import { imageColor } from "../utils/imageColor"; +import { pluralOrNot } from "../utils/pluralOrNot"; export default class User { data: SlashCommandOptionsOnlyBuilder; @@ -64,13 +65,14 @@ export default class User { const rolesLength = memberRoles.length; if (target.premiumSinceTimestamp) - serverInfo.push(`Boosting since **${target.premiumSinceTimestamp}**`); + serverInfo.push(`Boosting since ****`); if (memberRoles.length) serverInfo.push( - `**${guildRoles.filter(role => target.roles.cache.has(role.id)).size! - 1}** ${ - memberRoles.length == 1 ? "role" : "roles" - } • ${memberRoles + `**${guildRoles.filter(role => target.roles.cache.has(role.id)).size! - 1}** ${pluralOrNot( + "role", + memberRoles.length + )} • ${memberRoles .slice(0, 3) .map(role => `<@&${role[1].id}>`) .join(", ")}${rolesLength > 3 ? ` and **${rolesLength - 3}** more` : ""}` @@ -104,7 +106,7 @@ export default class User { 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 + 100 * difficulty * (level + 1) ** 2 - 80 * difficulty * level ** 2 )?.toLocaleString("en-US"); const collector = reply.createMessageComponentCollector({ time: 30000 }); diff --git a/src/commands/moderation/Ban.ts b/src/commands/moderation/Ban.ts index 0dbc4de..6cbb10f 100644 --- a/src/commands/moderation/Ban.ts +++ b/src/commands/moderation/Ban.ts @@ -1,8 +1,4 @@ -import { - PermissionsBitField, - SlashCommandSubcommandBuilder, - type ChatInputCommandInteraction -} from "discord.js"; +import { SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; import ms from "ms"; import { errorEmbed } from "../../utils/embeds/errorEmbed"; import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; @@ -30,7 +26,7 @@ export default class Ban { const reason = interaction.options.getString("reason"); if ( await errorCheck( - PermissionsBitField.Flags.BanMembers, + "BanMembers", { interaction, user, action: "Ban" }, { allErrors: true, botError: true, ownerError: true }, "Ban Members" @@ -60,14 +56,13 @@ export default class Ban { } try { + await modEmbed( + { interaction, user, action: "Banned", duration, dm: true, dbAction: "BAN", expiresAt }, + reason + ); 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 index 021d805..d518274 100644 --- a/src/commands/moderation/Cases.ts +++ b/src/commands/moderation/Cases.ts @@ -1,6 +1,5 @@ import { EmbedBuilder, - PermissionsBitField, SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; @@ -28,7 +27,8 @@ export default class Cases { WARN: "⚠️", MUTE: "🔇", KICK: "📤", - BAN: "🔨" + BAN: "🔨", + NOTE: "📝" }; const nothingMsg = [ @@ -39,11 +39,7 @@ export default class Cases { ]; const guild = interaction.guild!; - if ( - !guild.members.cache - .get(interaction.user.id) - ?.permissions.has(PermissionsBitField.Flags.ModerateMembers) - ) + if (!guild.members.cache.get(interaction.user.id)?.permissions.has("ModerateMembers")) return await errorEmbed( interaction, "You can't execute this command.", diff --git a/src/commands/moderation/Clear.ts b/src/commands/moderation/Clear.ts index 2332999..7860154 100644 --- a/src/commands/moderation/Clear.ts +++ b/src/commands/moderation/Clear.ts @@ -1,13 +1,13 @@ 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"; +import { pluralOrNot } from "../../utils/pluralOrNot"; export default class Clear { data: SlashCommandSubcommandBuilder; @@ -36,11 +36,7 @@ export default class Clear { async run(interaction: ChatInputCommandInteraction) { const guild = interaction.guild!; - if ( - !guild.members.cache - .get(interaction.user.id) - ?.permissions.has(PermissionsBitField.Flags.ManageMessages) - ) + if (!guild.members.cache.get(interaction.user.id)?.permissions.has("ManageMessages")) return await errorEmbed( interaction, "You can't execute this command.", @@ -56,7 +52,7 @@ export default class Clear { 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"}.` }) + .setAuthor({ name: `Cleared ${amount} ${pluralOrNot("message", amount)}.` }) .setDescription( [ `**Moderator**: ${interaction.user.displayName}`, @@ -73,7 +69,7 @@ export default class Clear { ) try { channel == interaction.channel - ? await channel.bulkDelete(amount + 1, true) + ? await channel.bulkDelete(amount, true) : await channel.bulkDelete(amount, true); } catch (error) { console.error(error); diff --git a/src/commands/moderation/Delwarn.ts b/src/commands/moderation/Delwarn.ts index 88af050..1a5e612 100644 --- a/src/commands/moderation/Delwarn.ts +++ b/src/commands/moderation/Delwarn.ts @@ -1,7 +1,6 @@ import { DMChannel, EmbedBuilder, - PermissionsBitField, SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; @@ -37,8 +36,8 @@ export default class Delwarn { const newWarns = warns.filter(warn => warn.id != `${id}`); if ( await errorCheck( - PermissionsBitField.Flags.ModerateMembers, - { interaction, user, action: "Remove a warn" }, + "ModerateMembers", + { interaction, user, action: "Remove a warning" }, { allErrors: true, botError: false }, "Moderate Members" ) @@ -46,7 +45,7 @@ export default class Delwarn { return; if (newWarns.length == warns.length) - return await errorEmbed(interaction, `There is no warn with the id of ${id}.`); + return await errorEmbed(interaction, `There is no warning with the id of ${id}.`); const embed = new EmbedBuilder() .setAuthor({ name: `• Removed a warning from ${name}`, iconURL: user.displayAvatarURL() }) @@ -65,6 +64,10 @@ export default class Delwarn { 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.")] }); + try { + await dmChannel.send({ embeds: [embed.setTitle("Your warning has been removed.")] }); + } catch (e) { + console.log(e); + } } } diff --git a/src/commands/moderation/Kick.ts b/src/commands/moderation/Kick.ts index c67bae8..bba3d51 100644 --- a/src/commands/moderation/Kick.ts +++ b/src/commands/moderation/Kick.ts @@ -1,8 +1,4 @@ -import { - PermissionsBitField, - SlashCommandSubcommandBuilder, - type ChatInputCommandInteraction -} from "discord.js"; +import { SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; import { errorEmbed } from "../../utils/embeds/errorEmbed"; import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; @@ -24,7 +20,7 @@ export default class Kick { const user = interaction.options.getUser("user")!; if ( await errorCheck( - PermissionsBitField.Flags.KickMembers, + "KickMembers", { interaction, user, action: "Kick" }, { allErrors: true, botError: true, ownerError: true, outsideError: true }, "Kick Members" @@ -40,11 +36,10 @@ export default class Kick { ); const reason = interaction.options.getString("reason"); + await modEmbed({ interaction, user, action: "Kicked", dm: true, dbAction: "KICK" }, 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 index 7197865..6a4aacc 100644 --- a/src/commands/moderation/Lock.ts +++ b/src/commands/moderation/Lock.ts @@ -1,7 +1,6 @@ import { ChannelType, EmbedBuilder, - PermissionsBitField, SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; @@ -30,11 +29,7 @@ export default class Lock { async run(interaction: ChatInputCommandInteraction) { const guild = interaction.guild!; - if ( - !guild.members.cache - .get(interaction.user.id) - ?.permissions.has(PermissionsBitField.Flags.ManageRoles) - ) + if (!guild.members.cache.get(interaction.user.id)?.permissions.has("ManageRoles")) return await errorEmbed( interaction, "You can't execute this command.", diff --git a/src/commands/moderation/Mute.ts b/src/commands/moderation/Mute.ts index b32ad71..17b116b 100644 --- a/src/commands/moderation/Mute.ts +++ b/src/commands/moderation/Mute.ts @@ -1,8 +1,4 @@ -import { - PermissionsBitField, - SlashCommandSubcommandBuilder, - type ChatInputCommandInteraction -} from "discord.js"; +import { SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; import ms from "ms"; import { errorEmbed } from "../../utils/embeds/errorEmbed"; import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; @@ -33,7 +29,7 @@ export default class Mute { const reason = interaction.options.getString("reason"); if ( await errorCheck( - PermissionsBitField.Flags.ModerateMembers, + "ModerateMembers", { interaction, user, action: "Mute" }, { allErrors: true, botError: true, ownerError: true }, "Moderate Members" @@ -52,15 +48,15 @@ export default class Mute { 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 ); + + await interaction.guild?.members.cache + .get(user.id) + ?.edit({ communicationDisabledUntil: time, reason: reason ?? undefined }) + .catch(error => console.error(error)); } } diff --git a/src/commands/moderation/Note.ts b/src/commands/moderation/Note.ts new file mode 100644 index 0000000..803ac28 --- /dev/null +++ b/src/commands/moderation/Note.ts @@ -0,0 +1,44 @@ +import { SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; +import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; + +export default class Note { + data: SlashCommandSubcommandBuilder; + constructor() { + this.data = new SlashCommandSubcommandBuilder() + .setName("note") + .setDescription("Add a note on a user.") + .addUserOption(user => + user.setName("user").setDescription("The user that you want to add a note on.").setRequired(true) + ) + .addStringOption(string => + string.setName("note").setDescription("The content of the user note.").setRequired(true) + ) + .addIntegerOption(bool => + bool + .setName("previous_note_id") + .setDescription( + "If provided, will modify the user note with the given case id instead of adding a new one." + ) + ); + } + + async run(interaction: ChatInputCommandInteraction) { + const user = interaction.options.getUser("user")!; + const note = interaction.options.getString("note"); + const previousID = interaction.options.getInteger("previous_note_id") ?? 0; + if ( + await errorCheck( + "ModerateMembers", + { interaction, user, action: "Annotate" }, + { allErrors: true, botError: false, ownerError: true, outsideError: true }, + "Moderate Members" + ) + ) + return; + + await modEmbed( + { interaction, user, action: "Annotated", dm: false, dbAction: "NOTE", previousID: previousID }, + note + ); + } +} diff --git a/src/commands/moderation/Slowdown.ts b/src/commands/moderation/Slowdown.ts index 3451595..4a5ee8f 100644 --- a/src/commands/moderation/Slowdown.ts +++ b/src/commands/moderation/Slowdown.ts @@ -1,7 +1,6 @@ import { ChannelType, EmbedBuilder, - PermissionsBitField, SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; @@ -42,11 +41,7 @@ export default class Slowdown { async run(interaction: ChatInputCommandInteraction) { const guild = interaction.guild!; - if ( - !guild.members.cache - .get(interaction.user.id) - ?.permissions.has(PermissionsBitField.Flags.ManageChannels) - ) + if (!guild.members.cache.get(interaction.user.id)?.permissions.has("ManageChannels")) return await errorEmbed( interaction, "You can't execute this command.", @@ -57,10 +52,10 @@ export default class Slowdown { 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), { + 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}`}\`.`; + if (!ms(time)) title = `Removed the slowdown from ${channelOption ?? `${channel.name}`}.`; const embed = new EmbedBuilder() .setAuthor({ name: title }) @@ -68,7 +63,7 @@ export default class Slowdown { [ `**Moderator**: ${interaction.user.displayName}`, reason ? `**Reason**: ${reason}` : "*No reason provided*", - `**Channel**: ${channelOption ?? `<#${channel.id}>**`}` + `**Channel**: ${channelOption ?? `<#${channel.id}>`}` ].join("\n") ) .setColor(genColor(100)); diff --git a/src/commands/moderation/Unban.ts b/src/commands/moderation/Unban.ts index a8d5d8e..50e5b0e 100644 --- a/src/commands/moderation/Unban.ts +++ b/src/commands/moderation/Unban.ts @@ -1,9 +1,4 @@ -import { - PermissionsBitField, - SlashCommandSubcommandBuilder, - type ChatInputCommandInteraction -} from "discord.js"; -import { errorEmbed } from "../../utils/embeds/errorEmbed"; +import { SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; export default class Unban { @@ -31,22 +26,15 @@ export default class Unban { if ( await errorCheck( - PermissionsBitField.Flags.BanMembers, + "BanMembers", { interaction, user: target, action: "Unban" }, - { allErrors: false, botError: true, ownerError: true }, + { allErrors: false, botError: true, ownerError: true, unbanError: 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); + await guild.members.unban(id, reason ?? undefined).catch(error => console.error(error)); } } diff --git a/src/commands/moderation/Unlock.ts b/src/commands/moderation/Unlock.ts index edba7ee..c722856 100644 --- a/src/commands/moderation/Unlock.ts +++ b/src/commands/moderation/Unlock.ts @@ -1,7 +1,6 @@ import { ChannelType, EmbedBuilder, - PermissionsBitField, SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; @@ -30,11 +29,7 @@ export default class Unlock { async run(interaction: ChatInputCommandInteraction) { const guild = interaction.guild!; - if ( - !guild.members.cache - .get(interaction.user.id) - ?.permissions.has(PermissionsBitField.Flags.ManageRoles) - ) + if (!guild.members.cache.get(interaction.user.id)?.permissions.has("ManageRoles")) return await errorEmbed( interaction, "You can't execute this command.", diff --git a/src/commands/moderation/Unmute.ts b/src/commands/moderation/Unmute.ts index 622e95b..3b02009 100644 --- a/src/commands/moderation/Unmute.ts +++ b/src/commands/moderation/Unmute.ts @@ -1,8 +1,4 @@ -import { - PermissionsBitField, - SlashCommandSubcommandBuilder, - type ChatInputCommandInteraction -} from "discord.js"; +import { SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; import { errorEmbed } from "../../utils/embeds/errorEmbed"; import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; @@ -22,7 +18,7 @@ export default class Unmute { const target = interaction.guild?.members.cache.get(user.id)!; if ( await errorCheck( - PermissionsBitField.Flags.ModerateMembers, + "ModerateMembers", { interaction, user, action: "Unmute" }, { allErrors: false, botError: true }, "Moderate Members" @@ -37,7 +33,7 @@ export default class Unmute { "The user was never muted." ); - await target.edit({ communicationDisabledUntil: null }).catch(error => console.error(error)); await modEmbed({ interaction, user, action: "Unmuted" }); + await target.edit({ communicationDisabledUntil: null }).catch(error => console.error(error)); } } diff --git a/src/commands/moderation/Warn.ts b/src/commands/moderation/Warn.ts index a458908..b90129d 100644 --- a/src/commands/moderation/Warn.ts +++ b/src/commands/moderation/Warn.ts @@ -1,8 +1,4 @@ -import { - PermissionsBitField, - SlashCommandSubcommandBuilder, - type ChatInputCommandInteraction -} from "discord.js"; +import { SlashCommandSubcommandBuilder, type ChatInputCommandInteraction } from "discord.js"; import { errorCheck, modEmbed } from "../../utils/embeds/modEmbed"; export default class Warn { @@ -32,7 +28,7 @@ export default class Warn { const showModerator = interaction.options.getBoolean("show_moderator") ?? false; if ( await errorCheck( - PermissionsBitField.Flags.ModerateMembers, + "ModerateMembers", { interaction, user, action: "Warn" }, { allErrors: true, botError: false, ownerError: true, outsideError: true }, "Moderate Members" diff --git a/src/commands/news/Add.ts b/src/commands/news/Add.ts index 4420702..d10eae5 100644 --- a/src/commands/news/Add.ts +++ b/src/commands/news/Add.ts @@ -2,7 +2,6 @@ import { ActionRowBuilder, EmbedBuilder, ModalBuilder, - PermissionsBitField, SlashCommandSubcommandBuilder, TextInputBuilder, TextInputStyle, @@ -21,11 +20,7 @@ export default class Add { async run(interaction: ChatInputCommandInteraction) { const guild = interaction.guild!; - if ( - !guild.members.cache - .get(interaction.user.id) - ?.permissions.has(PermissionsBitField.Flags.ManageGuild) - ) + if (!guild.members.cache.get(interaction.user.id)?.permissions.has("ManageGuild")) return await errorEmbed( interaction, "You can't execute this command.", diff --git a/src/commands/news/Edit.ts b/src/commands/news/Edit.ts index 48f3a0f..06841af 100644 --- a/src/commands/news/Edit.ts +++ b/src/commands/news/Edit.ts @@ -2,7 +2,6 @@ import { ActionRowBuilder, EmbedBuilder, ModalBuilder, - PermissionsBitField, SlashCommandSubcommandBuilder, TextInputBuilder, TextInputStyle, @@ -32,11 +31,7 @@ export default class Edit { async run(interaction: ChatInputCommandInteraction) { const guild = interaction.guild!; - if ( - !guild.members.cache - .get(interaction.user.id) - ?.permissions.has(PermissionsBitField.Flags.ManageGuild) - ) + if (!guild.members.cache.get(interaction.user.id)?.permissions.has("ManageGuild")) return await errorEmbed( interaction, "You can't execute this command.", @@ -44,7 +39,7 @@ export default class Edit { ); const id = interaction.options.getString("id")!; - const news = get(id); + const news = get(guild.id, id); if (!news) return await errorEmbed(interaction, "The specified news don't exist."); const firstActionRow = new ActionRowBuilder().addComponents( @@ -102,8 +97,8 @@ export default class Edit { content: roleToSend ? `<@&${roleToSend.id}>` : undefined }); - updateNews(id, title, body); - await interaction.reply({ + updateNews(guild.id, id, title, body); + await i.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 index 0cb5bee..596cb5d 100644 --- a/src/commands/news/Remove.ts +++ b/src/commands/news/Remove.ts @@ -1,6 +1,5 @@ import { EmbedBuilder, - PermissionsBitField, SlashCommandSubcommandBuilder, TextChannel, type ChatInputCommandInteraction @@ -26,11 +25,7 @@ export default class Remove { async run(interaction: ChatInputCommandInteraction) { const guild = interaction.guild!; - if ( - !guild.members.cache - .get(interaction.user.id) - ?.permissions.has(PermissionsBitField.Flags.ManageGuild) - ) + if (!guild.members.cache.get(interaction.user.id)?.permissions.has("ManageGuild")) return await errorEmbed( interaction, "You can't execute this command.", @@ -38,7 +33,7 @@ export default class Remove { ); const id = interaction.options.getString("id")!; - const news = get(id); + const news = get(guild.id, id); if (!news) return await errorEmbed(interaction, "The specified news don't exist."); const newsChannel = (await guild.channels @@ -46,7 +41,7 @@ export default class Remove { .catch(() => null)) as TextChannel; if (newsChannel) await newsChannel.messages.delete(news.messageID); - deleteNews(id); + deleteNews(guild.id, id); await interaction.reply({ embeds: [new EmbedBuilder().setTitle("News removed.").setColor(genColor(100))], ephemeral: true diff --git a/src/events/guildCreate.ts b/src/events/guildCreate.ts index dd2242d..66bbedd 100644 --- a/src/events/guildCreate.ts +++ b/src/events/guildCreate.ts @@ -1,16 +1,14 @@ -import { EmbedBuilder, type DMChannel } from "discord.js"; -import { Commands } from "../handlers/commands"; +import { EmbedBuilder } from "discord.js"; +import { commands } from "../handlers/commands"; import { genColor } from "../utils/colorGen"; -import { randomise } from "../utils/randomise"; +import { check } from "../utils/database/blocklist"; +import { leavePlease } from "../utils/leavePlease"; +import { replace } from "../utils/replace"; import { Event } from "../utils/types"; export default (async function run(guild) { - const dmChannel = (await (await guild.fetchOwner()).createDM().catch(() => null)) as - | DMChannel - | undefined; - - let emojis = ["💖", "💝", "💓", "💗", "💘", "💟", "💕", "💞"]; - if (Math.round(Math.random() * 100) <= 5) emojis = ["⌨️", "💻", "🖥️"]; + const owner = await guild.fetchOwner(); + if (!check(owner.id)) return await leavePlease(guild, owner, "No."); const client = guild.client; const embed = new EmbedBuilder() @@ -25,10 +23,17 @@ export default (async function run(guild) { "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` }) + .setFooter({ text: replace("(madeWith)") }) .setThumbnail(client.user.displayAvatarURL()) .setColor(genColor(200)); - await new Commands(client).registerCommandsForGuild(guild); - if (dmChannel) await dmChannel.send({ embeds: [embed] }); + await guild.commands.set(commands.map(command => command.data)); + try { + const welcomeChannel = guild.systemChannel; + if (welcomeChannel) + if (welcomeChannel.permissionsFor(guild.client.user)?.has("SendMessages")) + await welcomeChannel.send({ embeds: [embed] }); + } catch (e) { + console.log(e); + } } as Event<"guildCreate">); diff --git a/src/events/guildMemberAdd.ts b/src/events/guildMemberAdd.ts index 0d2ef0c..b2621dd 100644 --- a/src/events/guildMemberAdd.ts +++ b/src/events/guildMemberAdd.ts @@ -10,8 +10,14 @@ export default (async function run(member) { 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 }) + const replacement = [ + { text: "(name)", replacement: user.displayName }, + { text: "(count)", replacement: member.guild.memberCount }, + { text: "(servername)", replacement: member.guild.name } + ]; + + let 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)); @@ -21,7 +27,9 @@ export default (async function run(member) { .find(channel => channel.id == id) ?.fetch()) as TextChannel; - replace(member, getSetting(guildID, "welcome", "join_text") as string, embed); + embed.setDescription( + replace(getSetting(guildID, "welcome", "join_text") as string, replacement) + ); await channel.send({ embeds: [embed] }); } @@ -30,6 +38,10 @@ export default (async function run(member) { if (!dmChannel) return; if (user.bot) return; - replace(member, getSetting(guildID, "welcome", "dm_text") as string, embed); - await dmChannel.send({ embeds: [embed] }).catch(() => null); + embed.setDescription(replace(getSetting(guildID, "welcome", "dm_text") as string, replacement)); + try { + await dmChannel.send({ embeds: [embed] }).catch(() => null); + } catch (e) { + return console.log(e); + } } as Event<"guildMemberAdd">); diff --git a/src/events/guildMemberRemove.ts b/src/events/guildMemberRemove.ts index 784f41d..583a7f5 100644 --- a/src/events/guildMemberRemove.ts +++ b/src/events/guildMemberRemove.ts @@ -16,11 +16,17 @@ export default (async function run(member: GuildMember) { const avatar = member.displayAvatarURL(); const embed = new EmbedBuilder() - .setAuthor({ name: `• ${member.user.displayName} has left`, iconURL: avatar }) + .setAuthor({ name: `• ${member.user.displayName} has left.`, iconURL: avatar }) + .setDescription( + replace(getSetting(guildID, "welcome", "leave_text") as string, [ + { text: "(name)", replacement: member.user.displayName }, + { text: "(count)", replacement: member.guild.memberCount }, + { text: "(servername)", replacement: member.guild.name } + ]) + ) .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 index e5efaf6..01c6383 100644 --- a/src/events/interactionCreate.ts +++ b/src/events/interactionCreate.ts @@ -1,14 +1,29 @@ -import { Commands } from "../handlers/commands"; +import { commands, subCommands } from "../handlers/commands"; +import { check } from "../utils/database/blocklist"; +import { errorEmbed } from "../utils/embeds/errorEmbed"; import { Event } from "../utils/types"; export default (async function run(interaction) { if (!interaction.isChatInputCommand() && !interaction.isAutocomplete()) return; - const command = await new Commands(interaction.client).getCommand( - interaction.commandName, - interaction.options - ); + let command; + const subCommand = subCommands.filter( + subCommand => subCommand.data.name == interaction.options.getSubcommand(false) + )[0]; + + if (!subCommand) + command = commands.filter(command => command.data.name == interaction.commandName)[0]; + else command = subCommand; + if (!command) return; + if (interaction.isChatInputCommand()) { + if (!check(interaction.member?.user.id!)) + return await errorEmbed( + interaction, + "The bot has experienced an internal error.", + "Please try again later." + ); - if (interaction.isChatInputCommand()) command.run(interaction); + command.run(interaction); + } if (command.autocomplete) command.autocomplete(interaction); } as Event<"interactionCreate">); diff --git a/src/events/messageCreate.ts b/src/events/messageCreate.ts index 333e5dc..fc804bd 100644 --- a/src/events/messageCreate.ts +++ b/src/events/messageCreate.ts @@ -3,15 +3,48 @@ import { readdirSync } from "fs"; import { join } from "path"; import { pathToFileURL } from "url"; import { genColor } from "../utils/colorGen"; +import { add, check, remove } from "../utils/database/blocklist"; import { getLevel, setLevel } from "../utils/database/leveling"; import { getSetting } from "../utils/database/settings"; import { kominator } from "../utils/kominator"; +import { leavePlease } from "../utils/leavePlease"; import { Event } from "../utils/types"; const cooldowns = new Map(); export default (async function run(message) { + if (message.content.startsWith("!SYSTEM")) { + if (message.author.id != process.env.OWNER) return; + let args = message.content.split(" "); + + if (!args[2]) return message.reply("ERROR: Expected three arguments"); + const username = (await message.client.users.fetch(args[2])).username; + switch (args[1]) { + case "add": { + add(args[2]); + await message.reply(`${username} has been blocklisted from Sokora.`); + + const guilds = message.client.guilds.cache; + for (const id of guilds.keys()) + await leavePlease(guilds.get(id)!, await guilds.get(id)?.fetchOwner()!, "No."); + break; + } + case "remove": + remove(args[2]); + await message.reply(`${username} has been removed from the Sokora blocklist.`); + break; + case "check": + await message.reply(`${!check(args[2])}`); + break; + default: + await message.reply( + "Hello, this is the system interface to control top level Sokora moderation utilities." + ); + } + } + const author = message.author; if (author.bot) return; + if (!check(author.id)) return; const guild = message.guild!; // Easter egg handler @@ -43,21 +76,22 @@ export default (async function run(message) { 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 - 80 * difficulty * newLevelData.level ** 2 + ) + newLevelData.level--; while ( newLevelData.xp >= - 100 * difficulty * (newLevelData.level + 1) ** 2 - 85 * difficulty * newLevelData.level ** 2 + 100 * difficulty * (newLevelData.level + 1) ** 2 - 80 * difficulty * newLevelData.level ** 2 ) newLevelData.level++; setLevel(guild.id, author.id, newLevelData.level, newLevelData.xp); + if (newLevelData.level == level || newLevelData.level < level) return; const embed = new EmbedBuilder() .setAuthor({ name: `• ${author.displayName} has levelled up!`, @@ -66,8 +100,8 @@ export default (async function run(message) { .setDescription( [ `**Congratulations, ${author.displayName}**!`, - `You made it to **level ${level + 1}**`, - `You need ${Math.floor(100 * difficulty * (level + 2))} XP to level up again.` + `You made it to **level ${newLevelData.level}**.`, + `You need ${100 * difficulty * (newLevelData.level + 1) ** 2 - 80 * difficulty * newLevelData.level ** 2} XP to level up again.` ].join("\n") ) .setThumbnail(author.displayAvatarURL()) diff --git a/src/events/messageDelete.ts b/src/events/messageDelete.ts index 3f2d05f..9051681 100644 --- a/src/events/messageDelete.ts +++ b/src/events/messageDelete.ts @@ -1,4 +1,4 @@ -import { codeBlock, EmbedBuilder } from "discord.js"; +import { EmbedBuilder } from "discord.js"; import { genColor } from "../utils/colorGen"; import { getSetting } from "../utils/database/settings"; import { logChannel } from "../utils/logChannel"; @@ -16,12 +16,13 @@ export default (async function run(message) { name: `• ${author.displayName}'s message has been deleted.`, iconURL: author.displayAvatarURL() }) + .setDescription(`[Jump to message](${message.url})`) .addFields({ - name: "🗞️ • Deleted message", - value: codeBlock(message.content!) + name: "🗑️ • Deleted message", + value: message.content! }) - .setFooter({ text: `Message ID: ${message.id}\nUser ID: ${author.id}` }) - .setColor(genColor(60)); + .setFooter({ text: `User ID: ${author.id}` }) + .setColor(genColor(0)); await logChannel(guild, embed); } as Event<"messageDelete">); diff --git a/src/events/messageUpdate.ts b/src/events/messageUpdate.ts index f76ff05..e900a52 100644 --- a/src/events/messageUpdate.ts +++ b/src/events/messageUpdate.ts @@ -1,4 +1,4 @@ -import { codeBlock, EmbedBuilder } from "discord.js"; +import { EmbedBuilder } from "discord.js"; import { genColor } from "../utils/colorGen"; import { getSetting } from "../utils/database/settings"; import { logChannel } from "../utils/logChannel"; @@ -17,20 +17,21 @@ export default (async function run(oldMessage, newMessage) { const embed = new EmbedBuilder() .setAuthor({ - name: `• ${author.displayName}'s message has been edited`, + name: `• ${author.displayName} edited a message.`, iconURL: author.displayAvatarURL() }) + .setDescription(`[Jump to message](${oldMessage.url})`) .addFields( { - name: "🕰️ • Old message", - value: codeBlock(oldContent) + name: "🖋️ • Old message", + value: oldContent }, { - name: "🔄️ • New message", - value: codeBlock(newContent) + name: "🖊️ • New message", + value: newContent } ) - .setFooter({ text: `Message ID: ${oldMessage.id}\nUser ID: ${author.id}` }) + .setFooter({ text: `User ID: ${author.id}` }) .setColor(genColor(60)); await logChannel(guild, embed); diff --git a/src/handlers/commands.ts b/src/handlers/commands.ts index 740c7c2..a271bde 100644 --- a/src/handlers/commands.ts +++ b/src/handlers/commands.ts @@ -1,149 +1,89 @@ -import { - Guild, - SlashCommandBuilder, - SlashCommandSubcommandBuilder, - SlashCommandSubcommandGroupBuilder, - type Client -} from "discord.js"; +import { SlashCommandBuilder, SlashCommandSubcommandBuilder, type Client } from "discord.js"; import { readdirSync } from "fs"; import { join } from "path"; import { pathToFileURL } from "url"; import { getDisabledCommands } from "../utils/database/disabledCommands"; export let commands: { data: SlashCommandBuilder; run: any; autocomplete: any }[] = []; -let subcommands: { data: SlashCommandSubcommandBuilder; run: any; autocomplete: any }[] = []; -export class Commands { - client: Client; - constructor(client: Client) { - this.client = client; - } - - private async createSubCommand(name: string, ...disabledCommands: string[]) { - const commandsPath = join(process.cwd(), "src", "commands"); - const run = []; - const autocomplete = []; - const command = new SlashCommandBuilder() - .setName(name.toLowerCase()) - .setDescription("This command has no description."); - - 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 - ) +export let subCommands: { data: SlashCommandSubcommandBuilder; run: any; autocomplete: any }[] = []; +async function createSubCommand(name: string, client: Client, ...disabledCommands: string[]) { + const commandsPath = join(process.cwd(), "src", "commands"); + const run = []; + const autocomplete = []; + const command = new SlashCommandBuilder() + .setName(name.toLowerCase()) + .setDescription("This command has no description."); + + 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 ) - continue; - - if (subCommandFile.isFile()) { - const subCommandModule = await import( - pathToFileURL(join(commandsPath, name, subCommandFile.name)).toString() - ); - const subCommand = new subCommandModule.default(); - - command.addSubcommand(subCommand.data); - run.push(subCommand.run); - subcommands.push({ - data: subCommand.data, - run: subCommand.run, - autocomplete: subCommand.autocomplete - }); - - if ("autocompleteHandler" in subCommand) { - subCommand.autocompleteHandler(this.client); - autocomplete.push(subCommand.autocomplete); - } - continue; - } - - const subCommandGroup = new SlashCommandSubcommandGroupBuilder() - .setName(subCommandName.toLowerCase()) - .setDescription("This subcommand group has no description."); - - const subCommandGroupFiles = readdirSync(join(commandsPath, name, subCommandFile.name), { - withFileTypes: true - }); - for (const subCommandGroupFile of subCommandGroupFiles) { - if (!subCommandGroupFile.isFile()) continue; - if ( - disabledCommands?.find( - command => - command?.split("/")?.[0] == name && - command?.split("/")?.[1] == subCommandFile.name.replaceAll(".ts", "") && - command?.split("/")?.[2] == subCommandGroupFile.name.replaceAll(".ts", "") - ) - ) - continue; - - const subCommand = await import( - pathToFileURL( - join(commandsPath, name, subCommandFile.name, subCommandGroupFile.name) - ).toString() - ); - subCommandGroup.addSubcommand(new subCommand.default().data); - } - command.addSubcommandGroup(subCommandGroup); + ) + continue; + + if (!subCommandFile.isFile()) continue; + const subCommandModule = await import( + pathToFileURL(join(commandsPath, name, subCommandFile.name)).toString() + ); + const subCommand = new subCommandModule.default(); + + command.addSubcommand(subCommand.data); + run.push(subCommand.run); + subCommands.push({ + data: subCommand.data, + run: subCommand.run, + autocomplete: subCommand.autocomplete + }); + + if ("autocompleteHandler" in subCommand) { + subCommand.autocompleteHandler(client); + autocomplete.push(subCommand.autocomplete); } - - return { data: command, run: run, autocomplete: autocomplete }; } - async loadCommands(...disabledCommands: string[]) { - const commandsPath = join(process.cwd(), "src", "commands"); - 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()) { - const commandImport = await import(pathToFileURL(join(commandsPath, name)).toString()); - commands.push(new commandImport.default()); - continue; - } + return { data: command, run: run, autocomplete: autocomplete }; +} - const subCommand = await this.createSubCommand( - name, - join(commandsPath, name), - ...disabledCommands - ); +async function loadCommands(client: Client, ...disabledCommands: string[]) { + const commandsPath = join(process.cwd(), "src", "commands"); + for (const commandFile of readdirSync(commandsPath, { withFileTypes: true })) { + const name = commandFile.name; + if (disabledCommands?.includes(name.replaceAll(".ts", ""))) continue; - commands.push({ - data: subCommand.data, - run: subCommand.run, - autocomplete: subCommand.autocomplete - }); + if (commandFile.isFile()) { + const commandImport = await import(pathToFileURL(join(commandsPath, name)).toString()); + commands.push(new commandImport.default()); + continue; } - return commands; - } - - async registerCommandsForGuild(guild: Guild, ...disabledCommands: string[]) { - await this.loadCommands(...disabledCommands); - await guild.commands.set(commands.map(command => command.data)); + const subCommand = await createSubCommand( + name, + client, + join(commandsPath, name), + ...disabledCommands + ); + + commands.push({ + data: subCommand.data, + run: subCommand.run, + autocomplete: subCommand.autocomplete + }); } - async registerCommands(): Promise { - await this.loadCommands(); - const guilds = this.client.guilds.cache; - - for (const guildID of guilds.keys()) { - const disabledCommands = getDisabledCommands(guildID); - if (disabledCommands.length > 0) await this.loadCommands(...disabledCommands); - await guilds.get(guildID)?.commands.set(commands.map(command => command.data)); - } - - return commands; - } + return commands; +} - async getCommand(name: string, options: any) { - const subcommandName = options.getSubcommand(false); +export async function registerCommands(client: Client) { + await loadCommands(client); + const guilds = client.guilds.cache; - const command = commands.filter(command => command.data.name == name)[0]; - const subcommand = subcommands.filter(subcommand => subcommand.data.name == subcommandName)[0]; - if (!subcommand) return command; - return subcommand; + for (const guildID of guilds.keys()) { + const disabledCommands = getDisabledCommands(guildID); + if (disabledCommands.length > 0) await loadCommands(client, ...disabledCommands); + await guilds.get(guildID)?.commands.set(commands.map(command => command.data)); } } diff --git a/src/handlers/events.ts b/src/handlers/events.ts index 3ac1b39..707f232 100644 --- a/src/handlers/events.ts +++ b/src/handlers/events.ts @@ -3,24 +3,17 @@ import { readdirSync } from "fs"; import { join } from "path"; import { pathToFileURL } from "url"; -export class Events { - client: Client; - events: any[] = []; - constructor(client: Client) { - this.client = client; - } - - async loadEvents() { - const eventsPath = join(process.cwd(), "src", "events"); +let events = []; +export async function loadEvents(client: Client) { + const eventsPath = join(process.cwd(), "src", "events"); - for (const eventFile of readdirSync(eventsPath)) { - if (!eventFile.endsWith("ts")) continue; + for (const eventFile of readdirSync(eventsPath)) { + if (!eventFile.endsWith("ts")) continue; - const event = (await import(pathToFileURL(join(eventsPath, eventFile)).toString())).default; - const eventName = eventFile.split(".ts")[0]; - const clientEvent = this.client.on(eventName, event); + const event = (await import(pathToFileURL(join(eventsPath, eventFile)).toString())).default; + const eventName = eventFile.split(".ts")[0]; + const clientEvent = client.on(eventName, event); - this.events.push({ name: eventName, event: clientEvent }); - } + events.push({ name: eventName, event: clientEvent }); } } diff --git a/src/utils/database/blocklist.ts b/src/utils/database/blocklist.ts new file mode 100644 index 0000000..de74b72 --- /dev/null +++ b/src/utils/database/blocklist.ts @@ -0,0 +1,25 @@ +import { getDatabase } from "."; +import { TableDefinition } from "./types"; + +const tableDefinition: TableDefinition = { + name: "blocklist", + definition: { + id: "INTEGER" + } +}; + +const database = getDatabase(tableDefinition); +const checkQuery = database.query("SELECT * FROM blocklist WHERE id = $1;"); +export function check(userID: string) { + return checkQuery.all(userID).length == 0; +} + +const addQuery = database.query("INSERT INTO blocklist (id) VALUES (?1);"); +export function add(userID: string) { + addQuery.run(userID); +} + +const removeQuery = database.query("DELETE FROM blocklist WHERE id = $1;"); +export function remove(userID: string) { + removeQuery.run(userID); +} diff --git a/src/utils/database/moderation.ts b/src/utils/database/moderation.ts index ce3fc90..336dfd6 100644 --- a/src/utils/database/moderation.ts +++ b/src/utils/database/moderation.ts @@ -15,7 +15,7 @@ const definition = { } } satisfies TableDefinition; -export type modType = "MUTE" | "WARN" | "KICK" | "BAN"; +export type modType = "MUTE" | "WARN" | "KICK" | "BAN" | "NOTE"; 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);" @@ -29,6 +29,12 @@ 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 getLastIdQuery = database.query( + "SELECT CAST(id AS int) AS id FROM moderation ORDER BY id DESC LIMIT 1;" +); +const editQuery = database.query( + "UPDATE moderation SET reason = ?3, expiresAt = ?4 WHERE guild = ?1 AND id = ?2;" +); const removeQuery = database.query("DELETE FROM moderation WHERE guild = $1 AND id = $2"); export function addModeration( @@ -39,7 +45,8 @@ export function addModeration( reason = "", expiresAt?: number | null ) { - const id = listGuildQuery.all(guildID).length + 1; + let id: any = getLastIdQuery.all(guildID); + id = parseInt(id.length ? id[0].id : 0) + 1; addQuery.run(guildID, userID, type, moderator, reason, id, Date.now(), expiresAt ?? null); return id; } @@ -57,7 +64,7 @@ export function listUserModeration( 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; + if (modCase.length && modCase[0].user == userID) return modCase; return []; } @@ -65,6 +72,15 @@ export function listModeratorLog(guildID: number | string, moderator: number | s return listModQuery.all(guildID, moderator) as TypeOfDefinition[]; } +export function editModeration( + guildID: number | string, + id: string, + reason: string, + expiresAt?: number | null +) { + editQuery.run(guildID, id, reason, expiresAt ?? null); +} + export function removeModeration(guildID: string | number, id: string) { removeQuery.run(guildID, id); } diff --git a/src/utils/database/news.ts b/src/utils/database/news.ts index bfff50d..356ae40 100644 --- a/src/utils/database/news.ts +++ b/src/utils/database/news.ts @@ -21,8 +21,8 @@ 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"); +const getIdQuery = database.query("SELECT * FROM news WHERE guildID = $1 AND id = $2;"); +const deleteQuery = database.query("DELETE FROM news WHERE guildID = $1 AND id = $2;"); export function addNews( guildID: string, @@ -40,13 +40,13 @@ 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 get(guildID: string, id: string) { + return getIdQuery.get(guildID, id) as TypeOfDefinition | null; } -export function updateNews(id: string, title?: string, body?: string, messageID?: string) { - const lastElem = get(id)!; - deleteQuery.run(id); +export function updateNews(guildID: string, id: string, title?: string, body?: string, messageID?: string) { + const lastElem = get(guildID, id)!; + deleteQuery.run(guildID, id); sendQuery.run( lastElem.guildID, title ?? lastElem.title, @@ -60,6 +60,6 @@ export function updateNews(id: string, title?: string, body?: string, messageID? ); } -export function deleteNews(id: string) { - deleteQuery.run(id); +export function deleteNews(guildID: string, id: string) { + deleteQuery.run(guildID, id); } diff --git a/src/utils/database/settings.ts b/src/utils/database/settings.ts index f974ff0..779911d 100644 --- a/src/utils/database/settings.ts +++ b/src/utils/database/settings.ts @@ -27,17 +27,17 @@ export const settingsDefinition: Record< val: true }, channel: { - type: "TEXT", + type: "CHANNEL", desc: "ID of the log channel for leveling-related stuff (i.e someone leveling up)." }, block_channels: { - type: "TEXT", + type: "CHANNEL", 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 + val: 2 }, cooldown: { type: "INTEGER", @@ -55,7 +55,7 @@ export const settingsDefinition: Record< description: "Change where Sokora sends moderation logs.", settings: { channel: { - type: "TEXT", + type: "CHANNEL", desc: "ID of the log channel for moderation-related stuff (i.e a message being edited)." }, log_messages: { @@ -69,11 +69,11 @@ export const settingsDefinition: Record< description: "Configure news for your server.", settings: { channel_id: { - type: "TEXT", + type: "CHANNEL", desc: "ID of the channel where news messages are sent." }, role_id: { - type: "TEXT", + type: "ROLE", desc: "ID of the roles that should be pinged when a news message is sent." }, edit_original_message: { @@ -106,7 +106,7 @@ export const settingsDefinition: Record< 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." }, + channel: { type: "CHANNEL", 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.", @@ -168,6 +168,8 @@ export function getSetting< return (res[0].value === "1" ? true : false) as SqlType; case "INTEGER": return parseInt(res[0].value) as SqlType; + case "CHANNEL": + return res[0].value; case "LIST": return kominator(res[0].value) as SqlType; default: diff --git a/src/utils/database/types.ts b/src/utils/database/types.ts index fb02252..e4f4504 100644 --- a/src/utils/database/types.ts +++ b/src/utils/database/types.ts @@ -1,4 +1,13 @@ -export type FieldData = "TEXT" | "INTEGER" | "BOOL" | "TIMESTAMP" | "LIST"; +export type FieldData = + | "TEXT" + | "INTEGER" + | "BOOL" + | "TIMESTAMP" + | "CHANNEL" + | "USER" + | "ROLE" + | "COMMAND" + | "LIST"; export type TableDefinition = { name: string; @@ -10,6 +19,10 @@ export type SqlType = { INTEGER: number; TEXT: string; TIMESTAMP: Date; + CHANNEL: string; + USER: string; + ROLE: string; + COMMAND: string; LIST: any[]; }[T]; diff --git a/src/utils/embeds/modEmbed.ts b/src/utils/embeds/modEmbed.ts index e7ada38..a8cb820 100644 --- a/src/utils/embeds/modEmbed.ts +++ b/src/utils/embeds/modEmbed.ts @@ -1,7 +1,12 @@ -import { EmbedBuilder, type ChatInputCommandInteraction, type User } from "discord.js"; +import { + EmbedBuilder, + type PermissionResolvable, + type ChatInputCommandInteraction, + type User +} from "discord.js"; import ms from "ms"; import { genColor } from "../colorGen"; -import { addModeration, type modType } from "../database/moderation"; +import { getModeration, addModeration, editModeration, type modType } from "../database/moderation"; import { logChannel } from "../logChannel"; import { errorEmbed } from "./errorEmbed"; @@ -13,6 +18,7 @@ type Options = { dm?: boolean; dbAction?: modType; expiresAt?: number; + previousID?: number; }; type ErrorOptions = { @@ -20,22 +26,21 @@ type ErrorOptions = { botError: boolean; ownerError?: boolean; outsideError?: boolean; + unbanError?: boolean; }; export async function errorCheck( - permission: bigint, + permission: PermissionResolvable, options: Options, errorOptions: ErrorOptions, permissionAction: string ) { const { interaction, user, action } = options; - const { allErrors, botError, ownerError, outsideError } = errorOptions; + const { allErrors, botError, ownerError, outsideError, unbanError } = 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)) @@ -49,10 +54,23 @@ export async function errorCheck( return await errorEmbed( interaction, "You can't execute this command.", - `You're missing the **${permissionAction} Members** permission.` + `You're missing the **${permissionAction}** permission.` ); + if (unbanError) + if (!user) + return await errorEmbed( + interaction, + "You can't unban this user.", + "The user was never banned." + ); + if (!allErrors) return; + const target = members.get(user.id)!; + const name = user.displayName; + const highestModPos = member.roles.highest.position; + const highestTargetPos = target.roles.highest.position; + if (!target) return; if (target == member) return await errorEmbed(interaction, `You can't ${action.toLowerCase()} yourself.`); @@ -64,14 +82,14 @@ export async function errorCheck( return await errorEmbed( interaction, `You can't ${action.toLowerCase()} ${name}.`, - "The member has a higher role position than Sokora." + "The member has a higher (or the same) role position than Sokora." ); - if (member.roles.highest.position < target.roles.highest.position) + if (highestModPos <= highestTargetPos) return await errorEmbed( interaction, `You can't ${action.toLowerCase()} ${name}.`, - "The member has a higher role position than you." + `The member has ${highestModPos == highestTargetPos ? "the same" : "a higher"} role position ${highestModPos == highestTargetPos ? "as" : "than"} you.` ); if (ownerError) { @@ -83,7 +101,7 @@ export async function errorCheck( ); } - if (outsideError) { + if (outsideError) if ( !(await guild.members .fetch(user.id) @@ -95,7 +113,6 @@ export async function errorCheck( `You can't ${action.toLowerCase()} ${name}.`, "This user isn't in this server." ); - } } export async function modEmbed( @@ -103,27 +120,45 @@ export async function modEmbed( reason?: string | null, showModerator: boolean = false ) { - const { interaction, user, action, duration, dm, dbAction, expiresAt } = options; + const { interaction, user, action, duration, dm, dbAction, expiresAt, previousID } = options; const guild = interaction.guild!; const name = user.displayName; const generalValues = [`**Moderator**: ${interaction.user.displayName}`]; - let author = `• ${action} ${name}`; + let author = `• ${previousID ? "Edited a " : ""}${previousID ? dbAction?.toLowerCase() : action}${previousID ? " on" : ""} ${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 + if (previousID) { + let previousCase = getModeration(guild.id, user.id, `${previousID}`); + if ( + (!previousCase.length && previousCase[0].user != user.id) || + previousCase[0].type != dbAction + ) + return await errorEmbed( + interaction, + `You can't edit this ${dbAction?.toLowerCase()}.`, + `The ${dbAction?.toLowerCase()} doesn't exist.` ); - author = author.concat(` • #${id}`); + + try { + editModeration(guild.id, `${previousID}`, reason ?? "", expiresAt ?? null); } catch (error) { console.error(error); } + author = author.concat(` • #${previousID}`); + } else if (!dbAction) return; + + 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() @@ -139,17 +174,21 @@ export async function modEmbed( 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); + try { + 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); + } catch (e) { + return console.log(e); + } } diff --git a/src/utils/embeds/serverEmbed.ts b/src/utils/embeds/serverEmbed.ts index fd0b716..2af9256 100644 --- a/src/utils/embeds/serverEmbed.ts +++ b/src/utils/embeds/serverEmbed.ts @@ -7,6 +7,7 @@ import { EmbedBuilder, type Guild } from "discord.js"; import { genColor } from "../colorGen"; import { imageColor } from "../imageColor"; +import { pluralOrNot } from "../pluralOrNot"; type Options = { guild: Guild; @@ -20,9 +21,6 @@ export async function serverEmbed(options: Options) { const { premiumTier: boostTier, premiumSubscriptionCount: boostCount } = guild; 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 bots = members.filter(member => member.user.bot); const formattedUserCount = (guild.memberCount - bots.size)?.toLocaleString("en-US"); const icon = guild.iconURL()!; @@ -58,7 +56,7 @@ export async function serverEmbed(options: Options) { if (options.roles) embed.addFields({ - name: `🎭 • ${roles.size - 1} ${roles.size == 1 ? "role" : "roles"}`, + name: `🎭 • ${roles.size - 1} ${pluralOrNot("role", roles.size - 1)}`, value: roles.size == 1 ? "*None*" @@ -72,16 +70,16 @@ export async function serverEmbed(options: Options) { { name: `👥 • ${guild.memberCount?.toLocaleString("en-US")} members`, value: [ - `**${formattedUserCount}** users • **${bots.size?.toLocaleString("en-US")}** bots`, - `**${onlineMembers?.toLocaleString("en-US")}** online` + `**${formattedUserCount}** ${pluralOrNot("user", guild.memberCount - bots.size)}`, + `**${bots.size?.toLocaleString("en-US")}** ${pluralOrNot("bot", bots.size)}` ].join("\n"), inline: true }, { - name: `🗨️ • ${channelSizes.text + channelSizes.voice} channels`, + name: `🗨️ • ${channelSizes.text + channelSizes.voice} ${pluralOrNot("channel", channelSizes.text + channelSizes.voice)}`, value: [ `**${channelSizes.text}** text • **${channelSizes.voice}** voice`, - `**${channelSizes.categories}** categories` + `**${channelSizes.categories}** ${pluralOrNot("category", channelSizes.categories)}` ].join("\n"), inline: true }, @@ -90,8 +88,8 @@ export async function serverEmbed(options: Options) { value: [ `**${boostCount}**${ !boostTier ? "/2" : boostTier == 1 ? "/7" : boostTier == 2 ? "/14" : "" - } boosts`, - `**${boosters.size}** ${boosters.size == 1 ? "booster" : "boosters"}` + } ${pluralOrNot("boost", boostCount!)}`, + `**${boosters.size}** ${pluralOrNot("booster", boosters.size)}` ].join("\n"), inline: true } diff --git a/src/utils/leavePlease.ts b/src/utils/leavePlease.ts new file mode 100644 index 0000000..8d0de95 --- /dev/null +++ b/src/utils/leavePlease.ts @@ -0,0 +1,16 @@ +import { EmbedBuilder, type DMChannel, type Guild, type GuildMember } from "discord.js"; +import { genColor } from "./colorGen"; +import { check } from "./database/blocklist"; + +export async function leavePlease(guild: Guild, owner: GuildMember, embedText?: string) { + if (check(owner.id)) return; + if (embedText) { + const dmChannel = (await owner.createDM().catch(() => null)) as DMChannel | undefined; + if (dmChannel) + await dmChannel.send({ + embeds: [new EmbedBuilder().setTitle(embedText).setColor(genColor(0))] + }); + } + + return await guild.leave(); +} diff --git a/src/utils/logChannel.ts b/src/utils/logChannel.ts index a733617..94207df 100644 --- a/src/utils/logChannel.ts +++ b/src/utils/logChannel.ts @@ -34,5 +34,7 @@ export async function logChannel(guild: Guild, embed: EmbedBuilder) { }) .catch(() => null); - if (channel) return await channel.send({ embeds: [embed] }); + if (!channel) return; + if (!channel.permissionsFor(guild.client.user)?.has("ViewChannel")) return; + return await channel.send({ embeds: [embed] }); } diff --git a/src/utils/pluralOrNot.ts b/src/utils/pluralOrNot.ts new file mode 100644 index 0000000..448bfaa --- /dev/null +++ b/src/utils/pluralOrNot.ts @@ -0,0 +1,14 @@ +import { replace } from "./replace"; + +export function pluralOrNot(word: string, numToCheck: number) { + if (numToCheck != 1) + return (word = replace(word, [ + { + text: word, + replacement: + word.charAt(word.length - 1) == "y" ? `${word.replace("y", "")}ies` : `${word}s` + } + ])); + + return word; +} diff --git a/src/utils/replace.ts b/src/utils/replace.ts index 2d0f6a6..180a56c 100644 --- a/src/utils/replace.ts +++ b/src/utils/replace.ts @@ -1,10 +1,15 @@ -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); +import { randomise } from "./randomise"; + +let emojis = ["💖", "💝", "💓", "💗", "💘", "💟", "💕", "💞"]; +if (Math.round(Math.random() * 100) <= 5) emojis = ["⌨️", "💻", "🖥️"]; + +export const replacements = [ + { text: "(madeWith)", replacement: `Made with ${randomise(emojis)} by the Sokora team` } +]; + +export function replace(text: string, replaceText?: { text: string; replacement: any }[]) { + for (const mention of replaceText ? replaceText || replacements : replacements) + if (text?.includes(mention.text)) text = text.replaceAll(mention.text, mention.replacement); + + return text; } diff --git a/src/utils/sendChannelNews.ts b/src/utils/sendChannelNews.ts index b45eb2c..2a19d44 100644 --- a/src/utils/sendChannelNews.ts +++ b/src/utils/sendChannelNews.ts @@ -26,7 +26,7 @@ export async function sendChannelNews( title?: string, body?: string ) { - const news = get(id)!; + const news = get(guild.id, id)!; const role = getSetting(guild.id, "news", "role_id") as string; let roleToSend: Role | undefined; if (role) roleToSend = guild.roles.cache.get(role); @@ -39,14 +39,16 @@ export async function sendChannelNews( .setFooter({ text: `Latest news from ${guild.name}\nID: ${news.id}` }) .setColor(genColor(200)); - return ( - guild.channels.cache.get( - (getSetting(guild.id, "news", "channel_id") as string) ?? interaction.channel?.id - ) as TextChannel - ) + const channel = guild.channels.cache.get( + (getSetting(guild.id, "news", "channel_id") as string) ?? interaction.channel?.id + ) as TextChannel; + if (!channel) return; + if (!channel.permissionsFor(guild.client.user)?.has("ViewChannel")) return; + + return await channel .send({ embeds: [embed], content: roleToSend ? `<@&${roleToSend.id}>` : undefined }) - .then(message => updateNews(id, undefined, undefined, message.id)); + .then(message => updateNews(guild.id, id, undefined, undefined, message.id)); } diff --git a/src/utils/unbanScheduler.ts b/src/utils/unbanScheduler.ts index 599fccf..a1d6e3c 100644 --- a/src/utils/unbanScheduler.ts +++ b/src/utils/unbanScheduler.ts @@ -20,7 +20,7 @@ export function scheduleUnban( 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() }) + .setAuthor({ name: `• Unbanned ${user.displayName}.`, iconURL: user.displayAvatarURL() }) .setDescription( [`**Moderator**: ${moderator.displayName}`, "*Temporary ban has expired*"].join("\n") ) diff --git a/tsconfig.json b/tsconfig.json index 1a7a764..745512a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,8 +1,8 @@ { "compilerOptions": { - "lib": ["ESNext"], - "module": "esnext", - "target": "esnext", + "lib": ["ESNext", "ES2021.String"], + "module": "ESNext", + "target": "ESNext", "moduleResolution": "Node", "moduleDetection": "force", "allowImportingTsExtensions": true, @@ -17,4 +17,4 @@ "allowJs": true, "types": ["bun-types"] } -} \ No newline at end of file +}