diff --git a/src/commands/Admin/conf.ts b/src/commands/Admin/conf.ts index ba6a03e00f6..023219cf2db 100644 --- a/src/commands/Admin/conf.ts +++ b/src/commands/Admin/conf.ts @@ -1,4 +1,4 @@ -import { configurableGroups, isSchemaGroup, isSchemaKey, readSettings, remove, reset, SchemaKey, set, writeSettings } from '#lib/database'; +import { configurableGroups, isSchemaGroup, isSchemaKey, readSettings, remove, reset, SchemaKey, set, writeSettingsTransaction } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SettingsMenu, SkyraSubcommand } from '#lib/structures'; import type { GuildMessage } from '#lib/types'; @@ -62,11 +62,11 @@ export class UserCommand extends SkyraSubcommand { public async set(message: GuildMessage, args: SkyraSubcommand.Args) { const [key, schemaKey] = await this.fetchKey(args); - const response = await writeSettings(message.guild, async (settings) => { - await set(settings, schemaKey, args); - return schemaKey.display(settings, args.t); - }); + await using trx = await writeSettingsTransaction(message.guild); + await trx.write(await set(trx.settings, schemaKey, args)).submit(); + + const response = schemaKey.display(trx.settings, args.t); return send(message, { content: args.t(LanguageKeys.Commands.Admin.ConfUpdated, { key, response: this.getTextResponse(response) }), allowedMentions: { users: [], roles: [] } @@ -75,11 +75,11 @@ export class UserCommand extends SkyraSubcommand { public async remove(message: GuildMessage, args: SkyraSubcommand.Args) { const [key, schemaKey] = await this.fetchKey(args); - const response = await writeSettings(message.guild, async (settings) => { - await remove(settings, schemaKey, args); - return schemaKey.display(settings, args.t); - }); + await using trx = await writeSettingsTransaction(message.guild); + await trx.write(await remove(trx.settings, schemaKey, args)).submit(); + + const response = schemaKey.display(trx.settings, args.t); return send(message, { content: args.t(LanguageKeys.Commands.Admin.ConfUpdated, { key, response: this.getTextResponse(response) }), allowedMentions: { users: [], roles: [] } @@ -88,11 +88,11 @@ export class UserCommand extends SkyraSubcommand { public async reset(message: GuildMessage, args: SkyraSubcommand.Args) { const [key, schemaKey] = await this.fetchKey(args); - const response = await writeSettings(message.guild, async (settings) => { - reset(settings, schemaKey); - return schemaKey.display(settings, args.t); - }); + await using trx = await writeSettingsTransaction(message.guild); + await trx.write(reset(schemaKey)).submit(); + + const response = schemaKey.display(trx.settings, args.t); return send(message, { content: args.t(LanguageKeys.Commands.Admin.ConfReset, { key, value: response }), allowedMentions: { users: [], roles: [] } diff --git a/src/commands/Admin/roleset.ts b/src/commands/Admin/roleset.ts index 560d2879b16..42be2c7ea0b 100644 --- a/src/commands/Admin/roleset.ts +++ b/src/commands/Admin/roleset.ts @@ -1,4 +1,4 @@ -import { GuildEntity, readSettings, writeSettings, type UniqueRoleSet } from '#lib/database'; +import { GuildEntity, readSettings, writeSettings, writeSettingsTransaction, type UniqueRoleSet } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SkyraCommand, SkyraSubcommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; @@ -6,10 +6,12 @@ import { ApplyOptions } from '@sapphire/decorators'; import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; import { send } from '@sapphire/plugin-editable-commands'; +const Root = LanguageKeys.Commands.Admin; + @ApplyOptions({ aliases: ['rs'], - description: LanguageKeys.Commands.Admin.RoleSetDescription, - detailedDescription: LanguageKeys.Commands.Admin.RoleSetExtended, + description: Root.RoleSetDescription, + detailedDescription: Root.RoleSetExtended, permissionLevel: PermissionLevels.Administrator, runIn: [CommandOptionsRunTypeEnum.GuildAny], subcommands: [ @@ -32,15 +34,15 @@ export class UserCommand extends SkyraSubcommand { const roles = await args.repeat('roleName'); // Get all rolesets from settings and check if there is an existing set with the name provided by the user - await writeSettings(message.guild, (settings) => { + await writeSettings(message.guild, (settings) => ({ // The set does exist so we want to only REMOVE provided roles from it // Create a new array that we can use to overwrite the existing one in settings - settings.rolesUniqueRoleSets = settings.rolesUniqueRoleSets.map((set) => + rolesUniqueRoleSets: settings.rolesUniqueRoleSets.map((set) => set.name === name ? { name, roles: set.roles.filter((id: string) => !roles.find((role) => role.id === id)) } : set - ); - }); + ) + })); - return send(message, args.t(LanguageKeys.Commands.Admin.RoleSetRemoved, { name, roles: roles.map((role) => role.name) })); + return send(message, args.t(Root.RoleSetRemoved, { name, roles: roles.map((role) => role.name) })); } public async reset(message: GuildMessage, args: SkyraSubcommand.Args) { @@ -49,21 +51,21 @@ export class UserCommand extends SkyraSubcommand { // Get all rolesets from settings and check if there is an existing set with the name provided by the user this.#getUniqueRoleSets(message) ]); - if (sets.length === 0) this.error(LanguageKeys.Commands.Admin.RoleSetResetEmpty); + if (sets.length === 0) this.error(Root.RoleSetResetEmpty); if (!name) { - await writeSettings(message.guild, [['rolesUniqueRoleSets', []]]); - return send(message, args.t(LanguageKeys.Commands.Admin.RoleSetResetAll)); + await writeSettings(message.guild, { rolesUniqueRoleSets: [] }); + return send(message, args.t(Root.RoleSetResetAll)); } const arrayIndex = sets.findIndex((set) => set.name === name); - if (arrayIndex === -1) this.error(LanguageKeys.Commands.Admin.RoleSetResetNotExists, { name }); + if (arrayIndex === -1) this.error(Root.RoleSetResetNotExists, { name }); - await writeSettings(message.guild, (settings) => { - settings.rolesUniqueRoleSets.splice(arrayIndex, 1); + await writeSettings(message.guild, { + rolesUniqueRoleSets: sets.toSpliced(arrayIndex, 1) }); - return send(message, args.t(LanguageKeys.Commands.Admin.RoleSetResetGroup, { name })); + return send(message, args.t(Root.RoleSetResetGroup, { name })); } // This subcommand will run if a user doesn't type add or remove. The bot will then add AND remove based on whether that role is in the set already. @@ -92,15 +94,15 @@ export class UserCommand extends SkyraSubcommand { return { name, roles: newRoles }; }); - await writeSettings(message.guild, [['rolesUniqueRoleSets', newSets]]); - return send(message, args.t(LanguageKeys.Commands.Admin.RoleSetUpdated, { name })); + await writeSettings(message.guild, { rolesUniqueRoleSets: newSets }); + return send(message, args.t(Root.RoleSetUpdated, { name })); } // This subcommand will show the user a list of role sets and each role in that set. public async list(message: GuildMessage, args: SkyraCommand.Args) { // Get all rolesets from settings const sets = await this.#getUniqueRoleSets(message); - if (sets.length === 0) this.error(LanguageKeys.Commands.Admin.RoleSetNoRoleSets); + if (sets.length === 0) this.error(Root.RoleSetNoRoleSets); const list = await this.handleList(message, args, sets); return send(message, list.join('\n')); @@ -135,21 +137,23 @@ export class UserCommand extends SkyraSubcommand { if (changed) { // If after cleaning up, all sets end up empty, reset and return error: if (list.length === 0) { - await writeSettings(message.guild, [['rolesUniqueRoleSets', []]]); - this.error(LanguageKeys.Commands.Admin.RoleSetNoRoleSets); + await writeSettings(message.guild, { rolesUniqueRoleSets: [] }); + this.error(Root.RoleSetNoRoleSets); } // Else, clean up: - await writeSettings(message.guild, (settings) => this.cleanRoleSets(message, settings)); + await writeSettings(message.guild, (settings) => ({ + rolesUniqueRoleSets: this.cleanRoleSets(message, settings) + })); } return list; } - private cleanRoleSets(message: GuildMessage, settings: GuildEntity) { + private cleanRoleSets(message: GuildMessage, settings: Readonly) { const guildRoles = message.guild.roles.cache; - settings.rolesUniqueRoleSets = settings.rolesUniqueRoleSets + return settings.rolesUniqueRoleSets .map((set) => ({ name: set.name, roles: set.roles.filter((role) => guildRoles.has(role)) })) .filter((set) => set.roles.length > 0); } @@ -158,37 +162,27 @@ export class UserCommand extends SkyraSubcommand { const roles = await args.repeat('roleName'); // Get all rolesets from settings and check if there is an existing set with the name provided by the user - const created = await writeSettings(message.guild, (settings) => { - const allRoleSets = settings.rolesUniqueRoleSets; - const roleSet = allRoleSets.some((set) => set.name === name); - - // If it does not exist we need to create a brand new set - if (!roleSet) { - allRoleSets.push({ name, roles: roles.map((role) => role.id) }); - return true; - } + await using trx = await writeSettingsTransaction(message.guild); + + const entries = trx.settings.rolesUniqueRoleSets; + const index = entries.findIndex((set) => set.name === name); + // If it does not exist we need to create a brand new set + if (index === -1) { + const rolesUniqueRoleSets = entries.concat({ name, roles: roles.map((role) => role.id) }); + trx.write({ rolesUniqueRoleSets }); + } else { // The set does exist so we want to only ADD new roles in // Create a new array that we can use to overwrite the existing one in settings - const sets = allRoleSets.map((set) => { - if (set.name !== name) return set; - const finalRoles = [...set.roles]; - for (const role of roles) if (!finalRoles.includes(role.id)) finalRoles.push(role.id); - - return { name, roles: finalRoles }; - }); - settings.rolesUniqueRoleSets = sets; - - return false; - }); + const entry = entries[index]; + const rolesUniqueRoleSets = entries.with(index, { name, roles: entry.roles.concat(roles.map((role) => role.id)) }); + trx.write({ rolesUniqueRoleSets }); + } + await trx.submit(); - return send( - message, - args.t(created ? LanguageKeys.Commands.Admin.RoleSetCreated : LanguageKeys.Commands.Admin.RoleSetAdded, { - name, - roles: roles.map((role) => role.name) - }) - ); + const created = index === -1; + const key = created ? Root.RoleSetCreated : Root.RoleSetAdded; + return send(message, args.t(key, { name, roles: roles.map((role) => role.name) })); } async #getUniqueRoleSets(message: GuildMessage) { diff --git a/src/commands/Management/AutoModeration/automod-words.ts b/src/commands/Management/AutoModeration/automod-words.ts index 0eee1f49df4..d97f2873dcc 100644 --- a/src/commands/Management/AutoModeration/automod-words.ts +++ b/src/commands/Management/AutoModeration/automod-words.ts @@ -1,4 +1,4 @@ -import { readSettings, writeSettings, type GuildDataKey, type GuildDataValue, type GuildEntity } from '#lib/database'; +import { writeSettingsTransaction, type GuildDataKey, type GuildDataValue, type GuildEntity } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { getSupportedUserLanguageT } from '#lib/i18n/translate'; import { AutoModerationCommand } from '#lib/moderation'; @@ -49,31 +49,30 @@ export class UserAutoModerationCommand extends AutoModerationCommand { public async chatInputRunAdd(interaction: AutoModerationCommand.Interaction) { const word = this.#getWord(interaction); const { guild } = interaction; - const settings = await readSettings(guild); const t = getSupportedUserLanguageT(interaction); - if (await this.#hasWord(settings, word)) { + await using trx = await writeSettingsTransaction(guild); + if (await this.#hasWord(trx.settings, word)) { return interaction.reply({ content: t(Root.WordAddFiltered, { word }), ephemeral: true }); } - const updated = settings.selfmodFilterRaw.concat(word); - await writeSettings(guild, [['selfmodFilterRaw', updated]]); + await trx.write({ selfmodFilterRaw: trx.settings.selfmodFilterRaw.concat(word) }).submit(); return interaction.reply({ content: t(Root.EditSuccess), ephemeral: true }); } public async chatInputRunRemove(interaction: AutoModerationCommand.Interaction) { const word = this.#getWord(interaction); const { guild } = interaction; - const settings = await readSettings(guild); const t = getSupportedUserLanguageT(interaction); - const index = settings.selfmodFilterRaw.indexOf(word); + await using trx = await writeSettingsTransaction(guild); + + const index = trx.settings.selfmodFilterRaw.indexOf(word); if (index === -1) { return interaction.reply({ content: t(Root.WordRemoveNotFiltered, { word }), ephemeral: true }); } - const updated = settings.selfmodFilterRaw.toSpliced(index, 1); - await writeSettings(guild, [['selfmodFilterRaw', updated]]); + await trx.write({ selfmodFilterRaw: trx.settings.selfmodFilterRaw.toSpliced(index, 1) }).submit(); return interaction.reply({ content: t(Root.EditSuccess), ephemeral: true }); } @@ -123,7 +122,7 @@ export class UserAutoModerationCommand extends AutoModerationCommand { return removeConfusables(interaction.options.getString('word', true).toLowerCase()); } - async #hasWord(settings: GuildEntity, word: string) { + async #hasWord(settings: Readonly, word: string) { const words = settings.selfmodFilterRaw; if (words.includes(word)) return true; diff --git a/src/commands/Management/Configuration/manage-command-auto-delete.ts b/src/commands/Management/Configuration/manage-command-auto-delete.ts index 314106a668b..dd4d72f1516 100644 --- a/src/commands/Management/Configuration/manage-command-auto-delete.ts +++ b/src/commands/Management/Configuration/manage-command-auto-delete.ts @@ -1,4 +1,4 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings, writeSettings, writeSettingsTransaction, type CommandAutoDelete } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SkyraSubcommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; @@ -27,14 +27,15 @@ export class UserCommand extends SkyraSubcommand { const channel = await args.pick('textChannelName'); const time = await args.pick('timespan', { minimum: seconds(1), maximum: minutes(2) }); - await writeSettings(message.guild, (settings) => { - const { commandAutoDelete } = settings; - const index = commandAutoDelete.findIndex(([id]) => id === channel.id); - const value: readonly [string, number] = [channel.id, time]; + await using trx = await writeSettingsTransaction(message.guild); + const index = trx.settings.commandAutoDelete.findIndex(([id]) => id === channel.id); + const value: CommandAutoDelete = [channel.id, time]; - if (index === -1) commandAutoDelete.push(value); - else commandAutoDelete[index] = value; - }); + const commandAutoDelete = + index === -1 // + ? trx.settings.commandAutoDelete.concat(value) + : trx.settings.commandAutoDelete.with(index, value); + await trx.write({ commandAutoDelete }).submit(); const content = args.t(LanguageKeys.Commands.Management.ManageCommandAutoDeleteAdd, { channel: channel.toString(), time }); return send(message, content); @@ -42,23 +43,22 @@ export class UserCommand extends SkyraSubcommand { public async remove(message: GuildMessage, args: SkyraSubcommand.Args) { const channel = await args.pick('textChannelName'); - await writeSettings(message.guild, (settings) => { - const { commandAutoDelete } = settings; - const index = commandAutoDelete.findIndex(([id]) => id === channel.id); - if (index === -1) { - this.error(LanguageKeys.Commands.Management.ManageCommandAutoDeleteRemoveNotSet, { channel: channel.toString() }); - } + await using trx = await writeSettingsTransaction(message.guild); + const index = trx.settings.commandAutoDelete.findIndex(([id]) => id === channel.id); + if (index === -1) { + this.error(LanguageKeys.Commands.Management.ManageCommandAutoDeleteRemoveNotSet, { channel: channel.toString() }); + } - commandAutoDelete.splice(index, 1); - }); + const commandAutoDelete = trx.settings.commandAutoDelete.toSpliced(index, 1); + await trx.write({ commandAutoDelete }).submit(); const content = args.t(LanguageKeys.Commands.Management.ManageCommandAutoDeleteRemove, { channel: channel.toString() }); return send(message, content); } public async reset(message: GuildMessage, args: SkyraSubcommand.Args) { - await writeSettings(message.guild, [['commandAutoDelete', []]]); + await writeSettings(message.guild, { commandAutoDelete: [] }); const content = args.t(LanguageKeys.Commands.Management.ManageCommandAutoDeleteReset); return send(message, content); diff --git a/src/commands/Management/Configuration/manage-command-channel.ts b/src/commands/Management/Configuration/manage-command-channel.ts index 8fb91a7705f..5a75bcfc992 100644 --- a/src/commands/Management/Configuration/manage-command-channel.ts +++ b/src/commands/Management/Configuration/manage-command-channel.ts @@ -1,4 +1,4 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings, writeSettingsTransaction } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SkyraSubcommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; @@ -23,20 +23,29 @@ export class UserCommand extends SkyraSubcommand { public async add(message: GuildMessage, args: SkyraSubcommand.Args) { const channel = await args.pick('textChannelName'); const command = await args.pick('command'); - await writeSettings(message.guild, (settings) => { - const { disabledCommandsChannels } = settings; - const indexOfChannel = disabledCommandsChannels.findIndex((e) => e.channel === channel.id); - - if (indexOfChannel === -1) { - settings.disabledCommandsChannels.push({ channel: channel.id, commands: [command.name] }); - } else { - const disabledCommandChannel = disabledCommandsChannels[indexOfChannel]; - if (disabledCommandChannel.commands.includes(command.name)) - this.error(LanguageKeys.Commands.Management.ManageCommandChannelAddAlreadySet); - - settings.disabledCommandsChannels[indexOfChannel].commands.push(command.name); + + await using trx = await writeSettingsTransaction(message.guild); + + const index = trx.settings.disabledCommandsChannels.findIndex((entry) => entry.channel === channel.id); + if (index === -1) { + trx.write({ + disabledCommandsChannels: trx.settings.disabledCommandsChannels.concat({ channel: channel.id, commands: [command.name] }) + }); + } else { + const entry = trx.settings.disabledCommandsChannels[index]; + if (entry.commands.includes(command.name)) { + this.error(LanguageKeys.Commands.Management.ManageCommandChannelAddAlreadySet); } - }); + + trx.write({ + disabledCommandsChannels: trx.settings.disabledCommandsChannels.with(index, { + channel: channel.id, + commands: entry.commands.concat(command.name) + }) + }); + } + + await trx.submit(); const content = args.t(LanguageKeys.Commands.Management.ManageCommandChannelAdd, { channel: channel.toString(), command: command.name }); return send(message, content); @@ -45,25 +54,25 @@ export class UserCommand extends SkyraSubcommand { public async remove(message: GuildMessage, args: SkyraSubcommand.Args) { const channel = await args.pick('textChannelName'); const command = await args.pick('command'); - await writeSettings(message.guild, (settings) => { - const { disabledCommandsChannels } = settings; - const indexOfChannel = disabledCommandsChannels.findIndex((e) => e.channel === channel.id); - if (indexOfChannel === -1) { - this.error(LanguageKeys.Commands.Management.ManageCommandChannelRemoveNotSet, { channel: channel.toString() }); - } + await using trx = await writeSettingsTransaction(message.guild); - const disabledCommandChannel = disabledCommandsChannels[indexOfChannel]; - const indexOfDisabledCommand = disabledCommandChannel.commands.indexOf(command.name); + const index = trx.settings.disabledCommandsChannels.findIndex((entry) => entry.channel === channel.id); + if (index === -1) { + this.error(LanguageKeys.Commands.Management.ManageCommandChannelRemoveNotSet, { channel: channel.toString() }); + } - if (indexOfDisabledCommand !== -1) { - if (disabledCommandChannel.commands.length > 1) { - settings.disabledCommandsChannels[indexOfChannel].commands.splice(indexOfDisabledCommand, 1); - } else { - settings.disabledCommandsChannels.splice(indexOfChannel, 1); - } - } - }); + const entry = trx.settings.disabledCommandsChannels[index]; + const commandIndex = entry.commands.indexOf(command.name); + if (commandIndex === -1) { + this.error(LanguageKeys.Commands.Management.ManageCommandChannelRemoveNotSet, { channel: channel.toString() }); + } + + const disabledCommandsChannels = + entry.commands.length === 1 + ? trx.settings.disabledCommandsChannels.toSpliced(index, 1) + : trx.settings.disabledCommandsChannels.with(index, { channel: channel.id, commands: entry.commands.toSpliced(commandIndex, 1) }); + await trx.write({ disabledCommandsChannels }).submit(); const content = args.t(LanguageKeys.Commands.Management.ManageCommandChannelRemove, { channel: channel.toString(), command: command.name }); return send(message, content); @@ -71,16 +80,15 @@ export class UserCommand extends SkyraSubcommand { public async reset(message: GuildMessage, args: SkyraSubcommand.Args) { const channel = await args.pick('textChannelName'); - await writeSettings(message.guild, (settings) => { - const { disabledCommandsChannels } = settings; - const entryIndex = disabledCommandsChannels.findIndex((e) => e.channel === channel.id); - if (entryIndex === -1) { - this.error(LanguageKeys.Commands.Management.ManageCommandChannelResetEmpty); - } + await using trx = await writeSettingsTransaction(message.guild); - settings.disabledCommandsChannels.splice(entryIndex, 1); - }); + const index = trx.settings.disabledCommandsChannels.findIndex((entry) => entry.channel === channel.id); + if (index === -1) { + this.error(LanguageKeys.Commands.Management.ManageCommandChannelResetEmpty); + } + + await trx.write({ disabledCommandsChannels: trx.settings.disabledCommandsChannels.toSpliced(index, 1) }).submit(); const content = args.t(LanguageKeys.Commands.Management.ManageCommandChannelReset, { channel: channel.toString() }); return send(message, content); diff --git a/src/commands/Management/Configuration/manage-reaction-roles.ts b/src/commands/Management/Configuration/manage-reaction-roles.ts index 64438d24a4f..42727ed5d7d 100644 --- a/src/commands/Management/Configuration/manage-reaction-roles.ts +++ b/src/commands/Management/Configuration/manage-reaction-roles.ts @@ -1,4 +1,4 @@ -import { readSettings, writeSettings, type ReactionRole } from '#lib/database'; +import { readSettings, writeSettings, writeSettingsTransaction, type ReactionRole } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SkyraPaginatedMessage, SkyraSubcommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; @@ -38,9 +38,9 @@ export class UserCommand extends SkyraSubcommand { role: role.id }; - await writeSettings(message.guild, (settings) => { - settings.reactionRoles.push(reactionRole); - }); + await writeSettings(message.guild, (settings) => ({ + reactionRoles: settings.reactionRoles.concat(reactionRole) + })); const content = args.t(LanguageKeys.Commands.Management.ManageReactionRolesAddChannel, { emoji: getEmojiTextFormat(reactionRole.emoji), @@ -63,9 +63,9 @@ export class UserCommand extends SkyraSubcommand { channel: reaction.channel.id, role: role.id }; - await writeSettings(message.guild, (settings) => { - settings.reactionRoles.push(reactionRole); - }); + await writeSettings(message.guild, (settings) => ({ + reactionRoles: settings.reactionRoles.concat(reactionRole) + })); const url = ``; const content = args.t(LanguageKeys.Commands.Management.ManageReactionRolesAdd, { @@ -79,17 +79,12 @@ export class UserCommand extends SkyraSubcommand { const role = await args.pick('roleName'); const messageId = await args.pick('snowflake'); - const reactionRole = await writeSettings(message.guild, (settings) => { - const { reactionRoles } = settings; - const reactionRoleIndex = reactionRoles.findIndex((entry) => (entry.message ?? entry.channel) === messageId && entry.role === role.id); - - if (reactionRoleIndex === -1) this.error(LanguageKeys.Commands.Management.ManageReactionRolesRemoveNotExists); - - const removedReactionRole = reactionRoles[reactionRoleIndex]; - reactionRoles.splice(reactionRoleIndex, 1); + await using trx = await writeSettingsTransaction(message.guild); + const index = trx.settings.reactionRoles.findIndex((entry) => (entry.message ?? entry.channel) === messageId && entry.role === role.id); + if (index === -1) this.error(LanguageKeys.Commands.Management.ManageReactionRolesRemoveNotExists); - return removedReactionRole; - }); + const reactionRole = trx.settings.reactionRoles[index]; + await trx.write({ reactionRoles: trx.settings.reactionRoles.toSpliced(index, 1) }).submit(); const url = reactionRole.message ? `` @@ -103,15 +98,7 @@ export class UserCommand extends SkyraSubcommand { } public async reset(message: GuildMessage, args: SkyraSubcommand.Args) { - await writeSettings(message.guild, (settings) => { - const { reactionRoles } = settings; - if (reactionRoles.length === 0) { - this.error(LanguageKeys.Commands.Management.ManageReactionRolesResetEmpty); - } - - reactionRoles.length = 0; - }); - + await writeSettings(message.guild, { reactionRoles: [] }); const content = args.t(LanguageKeys.Commands.Management.ManageReactionRolesReset); return send(message, content); } diff --git a/src/commands/Management/Configuration/setIgnoreChannels.ts b/src/commands/Management/Configuration/setIgnoreChannels.ts index 44ce112b7b9..077b11644ba 100644 --- a/src/commands/Management/Configuration/setIgnoreChannels.ts +++ b/src/commands/Management/Configuration/setIgnoreChannels.ts @@ -1,4 +1,4 @@ -import { writeSettings } from '#lib/database'; +import { writeSettingsTransaction } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SkyraCommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; @@ -17,23 +17,14 @@ export class UserCommand extends SkyraCommand { public override async messageRun(message: GuildMessage, args: SkyraCommand.Args) { const channel = await args.pick(UserCommand.hereOrTextChannelResolver); - const [oldLength, newLength] = await writeSettings(message.guild, (settings) => { - const ignoredChannels = settings.disabledChannels; - const oldLength = ignoredChannels.length; + await using trx = await writeSettingsTransaction(message.guild); - const channelId = channel.id; - const index = ignoredChannels.indexOf(channelId); - if (index === -1) { - ignoredChannels.push(channelId); - } else { - ignoredChannels.splice(index, 1); - } + const index = trx.settings.disabledChannels.indexOf(channel.id); + const disabledChannels = index === -1 ? trx.settings.disabledChannels.concat(channel.id) : trx.settings.disabledChannels.splice(index, 1); + await trx.write({ disabledChannels }).submit(); - return [oldLength, ignoredChannels.length]; - }); - - const contentKey = - oldLength < newLength ? LanguageKeys.Commands.Management.SetIgnoreChannelsSet : LanguageKeys.Commands.Management.SetIgnoreChannelsRemoved; + const added = index === -1; + const contentKey = added ? LanguageKeys.Commands.Management.SetIgnoreChannelsSet : LanguageKeys.Commands.Management.SetIgnoreChannelsRemoved; const content = args.t(contentKey, { channel: channel.toString() }); return send(message, content); } diff --git a/src/commands/Management/Configuration/setPrefix.ts b/src/commands/Management/Configuration/setPrefix.ts index 58a00baea6d..a8c11be68c3 100644 --- a/src/commands/Management/Configuration/setPrefix.ts +++ b/src/commands/Management/Configuration/setPrefix.ts @@ -1,4 +1,4 @@ -import { writeSettings } from '#lib/database'; +import { writeSettingsTransaction } from '#lib/database'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SkyraCommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; @@ -16,15 +16,15 @@ import { send } from '@sapphire/plugin-editable-commands'; export class UserCommand extends SkyraCommand { public override async messageRun(message: GuildMessage, args: SkyraCommand.Args) { const prefix = await args.pick('string', { minimum: 1, maximum: 10 }); - await writeSettings(message.guild, (settings) => { - // If it's the same value, throw: - if (settings.prefix === prefix) { - this.error(LanguageKeys.Misc.ConfigurationEquals); - } - // Else set the new value: - settings.prefix = prefix; - }); + await using trx = await writeSettingsTransaction(message.guild); + + // If it's the same value, throw: + if (trx.settings.prefix === prefix) { + this.error(LanguageKeys.Misc.ConfigurationEquals); + } + + await trx.write({ prefix }).submit(); const content = args.t(LanguageKeys.Commands.Management.SetPrefixSet, { prefix }); return send(message, { content, allowedMentions: { users: [message.author.id], roles: [] } }); diff --git a/src/commands/Management/create-mute.ts b/src/commands/Management/create-mute.ts index 91cae2c483e..bb5e3b97c96 100644 --- a/src/commands/Management/create-mute.ts +++ b/src/commands/Management/create-mute.ts @@ -32,7 +32,7 @@ export class UserCommand extends SkyraCommand { const result = await this.askForRole(message, args, context); if (result.isOk()) { const role = result.unwrap(); - await writeSettings(message.guild, [['rolesMuted', role.id]]); + await writeSettings(message.guild, { rolesMuted: role.id }); if (canReact(message.channel)) return message.react(getEmojiReactionFormat(Emojis.GreenTickSerialized as SerializedEmoji)); const content = t(LanguageKeys.Commands.Admin.ConfUpdated, { diff --git a/src/commands/Management/permissionNodes.ts b/src/commands/Management/permissionNodes.ts index 5e6f6a5877e..db1d3f9dc2a 100644 --- a/src/commands/Management/permissionNodes.ts +++ b/src/commands/Management/permissionNodes.ts @@ -37,9 +37,9 @@ export class UserCommand extends SkyraSubcommand { } const command = await args.pick('commandMatch', { owners: false }); - await writeSettings(message.guild, (settings) => { - settings.permissionNodes.add(target, command, action); - }); + await writeSettings(message.guild, (settings) => ({ + [settings.permissionNodes.settingsPropertyFor(target)]: settings.permissionNodes.add(target, command, action) + })); const content = args.t(LanguageKeys.Commands.Management.PermissionNodesAdd); return send(message, content); @@ -52,9 +52,9 @@ export class UserCommand extends SkyraSubcommand { if (!this.checkPermissions(message, target)) this.error(LanguageKeys.Commands.Management.PermissionNodesHigher); - await writeSettings(message.guild, (settings) => { - settings.permissionNodes.remove(target, command, action); - }); + await writeSettings(message.guild, (settings) => ({ + [settings.permissionNodes.settingsPropertyFor(target)]: settings.permissionNodes.remove(target, command, action) + })); const content = args.t(LanguageKeys.Commands.Management.PermissionNodesRemove); return send(message, content); @@ -65,9 +65,9 @@ export class UserCommand extends SkyraSubcommand { if (!this.checkPermissions(message, target)) this.error(LanguageKeys.Commands.Management.PermissionNodesHigher); - await writeSettings(message.guild, (settings) => { - settings.permissionNodes.reset(target); - }); + await writeSettings(message.guild, (settings) => ({ + [settings.permissionNodes.settingsPropertyFor(target)]: settings.permissionNodes.reset(target) + })); const content = args.t(LanguageKeys.Commands.Management.PermissionNodesReset); return send(message, content); diff --git a/src/commands/Management/roles.ts b/src/commands/Management/roles.ts index 1e0d751145d..2e023f84d45 100644 --- a/src/commands/Management/roles.ts +++ b/src/commands/Management/roles.ts @@ -82,7 +82,7 @@ export class UserPaginatedMessageCommand extends SkyraCommand { if (actualInitialRole && settings.rolesRemoveInitial && addedRoles.length) { // If the role was deleted, remove it from the settings if (!message.guild.roles.cache.has(actualInitialRole)) { - await writeSettings(message.guild, [['rolesInitial', null]]).catch((error) => this.container.logger.fatal(error)); + await writeSettings(message.guild, { rolesInitial: null }).catch((error) => this.container.logger.fatal(error)); } else if (message.member!.roles.cache.has(actualInitialRole)) { memberRoles.delete(actualInitialRole); } @@ -117,7 +117,7 @@ export class UserPaginatedMessageCommand extends SkyraCommand { if (remove.length) { const allRoles = new Set(publicRoles); for (const role of remove) allRoles.delete(role); - await writeSettings(message.guild, [['rolesPublic', [...allRoles]]]); + await writeSettings(message.guild, { rolesPublic: [...allRoles] }); } // There's the possibility all roles could be inexistent, therefore the system diff --git a/src/lib/database/settings/SettingsManager.ts b/src/lib/database/settings/SettingsManager.ts index 40b807b0974..222f6c7ae80 100644 --- a/src/lib/database/settings/SettingsManager.ts +++ b/src/lib/database/settings/SettingsManager.ts @@ -1,9 +1,7 @@ -import { GuildSettingsCollection } from '#lib/database/settings/structures/collections/GuildSettingsCollection'; import { SerializerStore } from '#lib/database/settings/structures/SerializerStore'; import { TaskStore } from '#lib/database/settings/structures/TaskStore'; export class SettingsManager { public readonly serializers = new SerializerStore(); public readonly tasks = new TaskStore(); - public readonly guilds = new GuildSettingsCollection(); } diff --git a/src/lib/database/settings/Utils.ts b/src/lib/database/settings/Utils.ts index fc9f38047f1..c6e778884ea 100644 --- a/src/lib/database/settings/Utils.ts +++ b/src/lib/database/settings/Utils.ts @@ -2,7 +2,7 @@ import type { GuildEntity } from '#lib/database/entities/GuildEntity'; import type { ISchemaValue } from '#lib/database/settings/base/ISchemaValue'; import type { SchemaGroup } from '#lib/database/settings/schema/SchemaGroup'; import type { SchemaKey } from '#lib/database/settings/schema/SchemaKey'; -import { getT } from '#lib/i18n'; +import type { GuildData } from '#lib/database/settings/types'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import type { SkyraArgs } from '#lib/structures'; import { UserError } from '@sapphire/framework'; @@ -15,59 +15,52 @@ export function isSchemaKey(groupOrKey: ISchemaValue): groupOrKey is SchemaKey { return groupOrKey.type !== 'Group'; } -export async function set(settings: GuildEntity, key: SchemaKey, args: SkyraArgs) { +export async function set(settings: Readonly, key: SchemaKey, args: SkyraArgs): Promise> { const parsed = await key.parse(settings, args); + const { serializer } = key; + if (key.array) { - const values = Reflect.get(settings, key.property) as any[]; - const { serializer } = key; + const values = settings[key.property] as any[]; const index = values.findIndex((value) => serializer.equals(value, parsed)); - if (index === -1) values.push(parsed); - else values[index] = parsed; - } else { - const value = Reflect.get(settings, key.property); - const { serializer } = key; - if (serializer.equals(value, parsed)) { - throw new UserError({ - identifier: LanguageKeys.Settings.Gateway.DuplicateValue, - context: { - path: key.name, - value: key.stringify(settings, args.t, parsed) - } - }); - } - Reflect.set(settings, key.property, parsed); + return index === -1 // + ? { [key.property]: values.concat(parsed) } + : { [key.property]: values.with(index, parsed) }; + } + + if (serializer.equals(settings[key.property], parsed)) { + throw new UserError({ + identifier: LanguageKeys.Settings.Gateway.DuplicateValue, + context: { + path: key.name, + value: key.stringify(settings, args.t, parsed) + } + }); } - return getT(settings.language); + return { [key.property]: parsed }; } -export async function remove(settings: GuildEntity, key: SchemaKey, args: SkyraArgs) { +export async function remove(settings: Readonly, key: SchemaKey, args: SkyraArgs): Promise> { const parsed = await key.parse(settings, args); if (key.array) { - const values = Reflect.get(settings, key.property) as any[]; const { serializer } = key; + const values = settings[key.property] as any[]; + const index = values.findIndex((value) => serializer.equals(value, parsed)); if (index === -1) { throw new UserError({ identifier: LanguageKeys.Settings.Gateway.MissingValue, - context: { - path: key.name, - value: key.stringify(settings, args.t, parsed) - } + context: { path: key.name, value: key.stringify(settings, args.t, parsed) } }); } - values.splice(index, 1); - } else { - Reflect.set(settings, key.property, key.default); + return { [key.property]: values.toSpliced(index, 1) }; } - return getT(settings.language); + return { [key.property]: key.default }; } -export function reset(settings: GuildEntity, key: SchemaKey) { - const language = getT(settings.language); - Reflect.set(settings, key.property, key.default); - return language; +export function reset(key: SchemaKey): Partial { + return { [key.property]: key.default }; } diff --git a/src/lib/database/settings/base/SettingsCollection.ts b/src/lib/database/settings/base/SettingsCollection.ts deleted file mode 100644 index cbc54622264..00000000000 --- a/src/lib/database/settings/base/SettingsCollection.ts +++ /dev/null @@ -1,169 +0,0 @@ -import type { IBaseEntity } from '#lib/database/settings/base/IBaseEntity'; -import { RWLock } from 'async-rwlock'; -import { Collection } from 'discord.js'; - -export interface SettingsCollectionCallback { - (entity: T): Promise | R; -} - -export abstract class SettingsCollection extends Collection { - private readonly queue = new Map>(); - private readonly locks = new Map(); - - public override delete(key: string) { - this.locks.delete(key); - return super.delete(key); - } - - public async read(key: string): Promise { - const lock = this.acquireLock(key); - try { - // Acquire a read lock: - await lock.readLock(); - - // Fetch the entry: - return this.get(key) ?? (await this.processFetch(key)); - } finally { - // Unlock the lock: - lock.unlock(); - } - } - - public write(key: string, pairs: readonly [[K1, T[K1]]]): Promise; - public write(key: string, pairs: readonly [[K1, T[K1]], [K2, T[K2]]]): Promise; - public write( - key: string, - pairs: readonly [[K1, T[K1]], [K2, T[K2]], [K3, T[K3]]] - ): Promise; - - public write( - key: string, - pairs: readonly [[K1, T[K1]], [K2, T[K2]], [K3, T[K3]], [K4, T[K4]]] - ): Promise; - - public write( - key: string, - pairs: readonly [[K1, T[K1]], [K2, T[K2]], [K3, T[K3]], [K4, T[K4]], [K5, T[K5]]] - ): Promise; - - public write( - key: string, - pairs: readonly [[K1, T[K1]], [K2, T[K2]], [K3, T[K3]], [K4, T[K4]], [K5, T[K5]], [K6, T[K6]]] - ): Promise; - - public write< - K1 extends keyof T, - K2 extends keyof T, - K3 extends keyof T, - K4 extends keyof T, - K5 extends keyof T, - K6 extends keyof T, - K7 extends keyof T - >(key: string, pairs: readonly [[K1, T[K1]], [K2, T[K2]], [K3, T[K3]], [K4, T[K4]], [K5, T[K5]], [K6, T[K6]], [K7, T[K7]]]): Promise; - - public write< - K1 extends keyof T, - K2 extends keyof T, - K3 extends keyof T, - K4 extends keyof T, - K5 extends keyof T, - K6 extends keyof T, - K7 extends keyof T, - K8 extends keyof T - >( - key: string, - pairs: readonly [[K1, T[K1]], [K2, T[K2]], [K3, T[K3]], [K4, T[K4]], [K5, T[K5]], [K6, T[K6]], [K7, T[K7]], [K8, T[K8]]] - ): Promise; - - public write< - K1 extends keyof T, - K2 extends keyof T, - K3 extends keyof T, - K4 extends keyof T, - K5 extends keyof T, - K6 extends keyof T, - K7 extends keyof T, - K8 extends keyof T, - K9 extends keyof T - >( - key: string, - pairs: readonly [[K1, T[K1]], [K2, T[K2]], [K3, T[K3]], [K4, T[K4]], [K5, T[K5]], [K6, T[K6]], [K7, T[K7]], [K8, T[K8]], [K9, T[K9]]] - ): Promise; - - public write(key: string, pairs: readonly [K, T[K]][]): Promise; - public write(key: string, cb: SettingsCollectionCallback): Promise; - public async write(key: string, cb: readonly [keyof T, T[keyof T]][] | SettingsCollectionCallback): Promise { - const lock = this.acquireLock(key); - - // Acquire a write lock: - await lock.writeLock(); - - // Fetch the entry: - const settings = this.get(key) ?? (await this.unlockOnThrow(this.processFetch(key), lock)); - - try { - // If a callback was given, call it, receive its return, save the settings, and return: - if (typeof cb === 'function') { - const result = await cb(settings); - await settings.save(); - return result; - } - - // Otherwise, for each key, we set the value: - for (const [k, v] of cb) { - settings[k] = v; - } - - // Now we save, and return undefined: - await settings.save(); - return undefined; - } catch (error) { - await this.tryReload(settings); - throw error; - } finally { - lock.unlock(); - } - } - - public abstract fetch(key: string): Promise; - - private async tryReload(entity: T): Promise { - try { - await entity.reload(); - } catch (error) { - if (error instanceof Error && error.name === 'EntityNotFound') entity.resetAll(); - else throw error; - } - } - - private async unlockOnThrow(promise: Promise, lock: RWLock) { - try { - return await promise; - } catch (error) { - lock.unlock(); - throw error; - } - } - - private async processFetch(key: string): Promise { - const previous = this.queue.get(key); - if (previous) return previous; - - try { - const promise = this.fetch(key); - this.queue.set(key, promise); - return await promise; - } finally { - this.queue.delete(key); - } - } - - private acquireLock(key: string): RWLock { - const previous = this.locks.get(key); - if (previous) return previous; - - const lock = new RWLock(); - this.locks.set(key, lock); - return lock; - } -} diff --git a/src/lib/database/settings/functions.ts b/src/lib/database/settings/functions.ts index 7a82d3d7c59..fa596405ba8 100644 --- a/src/lib/database/settings/functions.ts +++ b/src/lib/database/settings/functions.ts @@ -1,61 +1,195 @@ -import type { GuildEntity } from '#lib/database/entities/GuildEntity'; -import type { SettingsCollectionCallback } from '#lib/database/settings/base/SettingsCollection'; -import { container } from '@sapphire/framework'; -import type { GuildResolvable } from 'discord.js'; +import { GuildEntity } from '#lib/database/entities/GuildEntity'; +import { container, type Awaitable } from '@sapphire/framework'; +import { RWLock } from 'async-rwlock'; +import { Collection, type GuildResolvable, type Snowflake } from 'discord.js'; -type K = keyof V; -type V = GuildEntity; +const cache = new Collection(); +const queue = new Collection>(); +const locks = new Collection(); -export function readSettings(guild: GuildResolvable) { - const resolved = container.client.guilds.resolveId(guild); - if (resolved === null) throw new TypeError(`Cannot resolve "guild" to a Guild instance.`); - return container.settings.guilds.read(resolved); +export function deleteSettingsCached(guild: GuildResolvable) { + const id = resolveGuildId(guild); + locks.delete(id); + cache.delete(id); } -export function writeSettings(guild: GuildResolvable, pairs: readonly [[K1, V[K1]]]): Promise; -export function writeSettings(guild: GuildResolvable, pairs: readonly [[K1, V[K1]], [K2, V[K2]]]): Promise; -export function writeSettings( - guild: GuildResolvable, - pairs: readonly [[K1, V[K1]], [K2, V[K2]], [K3, V[K3]]] -): Promise; -export function writeSettings( - guild: GuildResolvable, - pairs: readonly [[K1, V[K1]], [K2, V[K2]], [K3, V[K3]], [K4, V[K4]]] -): Promise; -export function writeSettings( - guild: GuildResolvable, - pairs: readonly [[K1, V[K1]], [K2, V[K2]], [K3, V[K3]], [K4, V[K4]], [K5, V[K5]]] -): Promise; -export function writeSettings( - guild: GuildResolvable, - pairs: readonly [[K1, V[K1]], [K2, V[K2]], [K3, V[K3]], [K4, V[K4]], [K5, V[K5]], [K6, V[K6]]] -): Promise; -export function writeSettings( - guild: GuildResolvable, - pairs: readonly [[K1, V[K1]], [K2, V[K2]], [K3, V[K3]], [K4, V[K4]], [K5, V[K5]], [K6, V[K6]], [K7, V[K7]]] -): Promise; -export function writeSettings( - guild: GuildResolvable, - pairs: readonly [[K1, V[K1]], [K2, V[K2]], [K3, V[K3]], [K4, V[K4]], [K5, V[K5]], [K6, V[K6]], [K7, V[K7]], [K8, V[K8]]] -): Promise; -export function writeSettings< - K1 extends K, - K2 extends K, - K3 extends K, - K4 extends K, - K5 extends K, - K6 extends K, - K7 extends K, - K8 extends K, - K9 extends K ->( +export async function readSettings(guild: GuildResolvable): Promise { + const id = resolveGuildId(guild); + + const lock = locks.ensure(id, () => new RWLock()); + try { + // Acquire a read lock: + await lock.readLock(); + + // Fetch the entry: + return cache.get(id) ?? (await processFetch(id)); + } finally { + // Unlock the lock: + lock.unlock(); + } +} + +export function readSettingsCached(guild: GuildResolvable): GuildEntity | null { + return cache.get(resolveGuildId(guild)) ?? null; +} + +export async function writeSettings( guild: GuildResolvable, - pairs: readonly [[K1, V[K1]], [K2, V[K2]], [K3, V[K3]], [K4, V[K4]], [K5, V[K5]], [K6, V[K6]], [K7, V[K7]], [K8, V[K8]], [K9, V[K9]]] -): Promise; -export function writeSettings(guild: GuildResolvable, pairs: readonly [KX, V[KX]][]): Promise; -export function writeSettings(guild: GuildResolvable, cb: SettingsCollectionCallback): Promise; -export function writeSettings(guild: GuildResolvable, paths: any) { - const resolved = container.client.guilds.resolveId(guild); - if (resolved === null) throw new TypeError(`Cannot resolve "guild" to a Guild instance.`); - return container.settings.guilds.write(resolved, paths); + data: Readonly> | ((settings: Readonly) => Awaitable>>) +) { + const id = resolveGuildId(guild); + const lock = locks.ensure(id, () => new RWLock()); + + // Acquire a write lock: + await lock.writeLock(); + + // Fetch the entry: + const settings = cache.get(id) ?? (await unlockOnThrow(processFetch(id), lock)); + + try { + if (typeof data === 'function') { + data = await data(settings); + } + + Object.assign(settings, data); + + // Now we save, and return undefined: + await settings.save(); + return settings; + } catch (error) { + await tryReload(settings); + throw error; + } finally { + lock.unlock(); + } +} + +export async function writeSettingsTransaction(guild: GuildResolvable) { + const id = resolveGuildId(guild); + const lock = locks.ensure(id, () => new RWLock()); + + // Acquire a write lock: + await lock.writeLock(); + + // Fetch the entry: + const settings = cache.get(id) ?? (await unlockOnThrow(processFetch(id), lock)); + + return new Transaction(settings, lock); +} + +export class Transaction { + #hasChanges = false; + #locking = true; + + public constructor( + public readonly settings: Readonly, + private readonly lock: RWLock + ) {} + + public get hasChanges() { + return this.#hasChanges; + } + + public get locking() { + return this.#locking; + } + + public write(data: Readonly>) { + Object.assign(this.settings, data); + this.#hasChanges = true; + return this; + } + + public async submit() { + if (!this.#hasChanges) { + throw new Error('Cannot submit a transaction without changes'); + } + + try { + await this.settings.save(); + this.#hasChanges = false; + } catch (error) { + await tryReload(this.settings); + throw error; + } finally { + if (this.#locking) { + this.lock.unlock(); + this.#locking = false; + } + } + } + + public async abort() { + try { + await tryReload(this.settings); + } finally { + this.lock.unlock(); + } + } + + public async dispose() { + if (!this.#hasChanges) { + await this.abort(); + } + + if (this.#locking) { + this.lock.unlock(); + this.#locking = false; + } + } + + public [Symbol.asyncDispose]() { + return this.dispose(); + } +} + +async function tryReload(entity: Readonly): Promise { + try { + await entity.reload(); + } catch (error) { + if (error instanceof Error && error.name === 'EntityNotFound') entity.resetAll(); + else throw error; + } +} + +async function unlockOnThrow(promise: Promise, lock: RWLock) { + try { + return await promise; + } catch (error) { + lock.unlock(); + throw error; + } +} + +async function processFetch(id: string): Promise { + const previous = queue.get(id); + if (previous) return previous; + + try { + const promise = fetch(id); + queue.set(id, promise); + return await promise; + } finally { + queue.delete(id); + } +} + +async function fetch(id: string): Promise { + const { guilds } = container.db; + const existing = await guilds.findOne({ where: { id } }); + if (existing) { + cache.set(id, existing); + return existing; + } + + const created = new GuildEntity(); + created.id = id; + cache.set(id, created); + return created; +} + +function resolveGuildId(guild: GuildResolvable): Snowflake { + const resolvedId = container.client.guilds.resolveId(guild); + if (resolvedId === null) throw new TypeError(`Cannot resolve "guild" to a Guild instance.`); + return resolvedId; } diff --git a/src/lib/database/settings/index.ts b/src/lib/database/settings/index.ts index 2eebaa6ca4a..c7799d7acd4 100644 --- a/src/lib/database/settings/index.ts +++ b/src/lib/database/settings/index.ts @@ -1,13 +1,11 @@ export * from '#lib/database/settings/base/IBaseEntity'; export * from '#lib/database/settings/base/IBaseManager'; -export * from '#lib/database/settings/base/SettingsCollection'; export * from '#lib/database/settings/configuration'; export * from '#lib/database/settings/functions'; export * from '#lib/database/settings/schema/SchemaGroup'; export * from '#lib/database/settings/schema/SchemaKey'; export * from '#lib/database/settings/SettingsManager'; export * from '#lib/database/settings/structures/AdderManager'; -export * from '#lib/database/settings/structures/collections/GuildSettingsCollection'; export * from '#lib/database/settings/structures/PermissionNodeManager'; export * from '#lib/database/settings/structures/Serializer'; export * from '#lib/database/settings/structures/SerializerStore'; diff --git a/src/lib/database/settings/schema/SchemaKey.ts b/src/lib/database/settings/schema/SchemaKey.ts index a4123f60697..dff78c4d516 100644 --- a/src/lib/database/settings/schema/SchemaKey.ts +++ b/src/lib/database/settings/schema/SchemaKey.ts @@ -79,13 +79,13 @@ export class SchemaKey implements ISchema this.dashboardOnly = options.dashboardOnly ?? false; } - public get serializer(): Serializer { + public get serializer(): Serializer[K]> { const value = container.settings.serializers.get(this.type); if (typeof value === 'undefined') throw new Error(`The serializer for '${this.type}' does not exist.`); - return value as Serializer; + return value as Serializer[K]>; } - public async parse(settings: GuildEntity, args: SkyraArgs): Promise { + public async parse(settings: Readonly, args: SkyraArgs): Promise[K]> { const { serializer } = this; const context = this.getContext(settings, args.t); @@ -98,13 +98,13 @@ export class SchemaKey implements ISchema }); } - public stringify(settings: GuildEntity, t: TFunction, value: GuildEntity[K]): string { + public stringify(settings: Readonly, t: TFunction, value: GuildEntity[K]): string { const { serializer } = this; const context = this.getContext(settings, t); return serializer.stringify(value, context); } - public display(settings: GuildEntity, t: TFunction): string { + public display(settings: Readonly, t: TFunction): string { const { serializer } = this; const context = this.getContext(settings, t); @@ -119,7 +119,7 @@ export class SchemaKey implements ISchema return isNullish(value) ? t(LanguageKeys.Commands.Admin.ConfSettingNotSet) : serializer.stringify(value, context); } - public getContext(settings: GuildEntity, language: TFunction): Serializer.UpdateContext { + public getContext(settings: Readonly, language: TFunction): Serializer.UpdateContext { return { entity: settings, guild: settings.guild, diff --git a/src/lib/database/settings/structures/PermissionNodeManager.ts b/src/lib/database/settings/structures/PermissionNodeManager.ts index 6b265a2608d..46036e79517 100644 --- a/src/lib/database/settings/structures/PermissionNodeManager.ts +++ b/src/lib/database/settings/structures/PermissionNodeManager.ts @@ -18,13 +18,17 @@ type Node = Nodes[number]; export class PermissionNodeManager implements IBaseManager { private sorted = new Collection(); - #settings: GuildEntity; + #settings: Readonly; #previous: Nodes = []; public constructor(settings: GuildEntity) { this.#settings = settings; } + public settingsPropertyFor(target: Role | GuildMember | User) { + return (target instanceof Role ? 'permissionsRoles' : 'permissionsUsers') satisfies keyof GuildEntity; + } + public run(member: GuildMember, command: SkyraCommand) { return this.runUser(member, command) ?? this.runRole(member, command); } @@ -33,9 +37,9 @@ export class PermissionNodeManager implements IBaseManager { return this.sorted.has(roleId); } - public add(target: Role | GuildMember | User, command: string, action: PermissionNodeAction) { - const key: keyof GuildEntity = target instanceof Role ? 'permissionsRoles' : 'permissionsUsers'; - const nodes = this.#settings[key]; + public add(target: Role | GuildMember | User, command: string, action: PermissionNodeAction): PermissionsNode[] { + const key = this.settingsPropertyFor(target); + const nodes = this.#settings[key].slice(); const nodeIndex = nodes.findIndex((n) => n.id === target.id); if (nodeIndex === -1) { @@ -45,7 +49,7 @@ export class PermissionNodeManager implements IBaseManager { deny: action === PermissionNodeAction.Deny ? [command] : [] }; - this.#settings[key].push(node); + nodes.push(node); } else { const previous = nodes[nodeIndex]; if ( @@ -61,13 +65,15 @@ export class PermissionNodeManager implements IBaseManager { deny: action === PermissionNodeAction.Deny ? previous.deny.concat(command) : previous.deny }; - this.#settings[key][nodeIndex] = node; + nodes[nodeIndex] = node; } + + return nodes; } - public remove(target: Role | GuildMember | User, command: string, action: PermissionNodeAction) { - const key: keyof GuildEntity = target instanceof Role ? 'permissionsRoles' : 'permissionsUsers'; - const nodes = this.#settings[key]; + public remove(target: Role | GuildMember | User, command: string, action: PermissionNodeAction): PermissionsNode[] { + const key = this.settingsPropertyFor(target); + const nodes = this.#settings[key].slice(); const nodeIndex = nodes.findIndex((n) => n.id === target.id); if (nodeIndex === -1) throw new UserError({ identifier: LanguageKeys.Commands.Management.PermissionNodesNodeNotExists }); @@ -77,18 +83,19 @@ export class PermissionNodeManager implements IBaseManager { const commandIndex = previous[property].indexOf(command); if (commandIndex === -1) throw new UserError({ identifier: LanguageKeys.Commands.Management.PermissionNodesCommandNotExists }); - const node: Nodes[number] = { + const node: PermissionsNode = { id: target.id, allow: 'allow' ? previous.allow.slice() : previous.allow, deny: 'deny' ? previous.deny.slice() : previous.deny }; node[property].splice(commandIndex, 1); - this.#settings[key].splice(nodeIndex, 1, node); + nodes.splice(nodeIndex, 1, node); + return nodes; } - public reset(target: Role | GuildMember | User) { - const key: keyof GuildEntity = target instanceof Role ? 'permissionsRoles' : 'permissionsUsers'; + public reset(target: Role | GuildMember | User): PermissionsNode[] { + const key = this.settingsPropertyFor(target); const nodes = this.#settings[key]; const nodeIndex = nodes.findIndex((n) => n.id === target.id); @@ -96,11 +103,11 @@ export class PermissionNodeManager implements IBaseManager { throw new UserError({ identifier: LanguageKeys.Commands.Management.PermissionNodesNodeNotExists, context: { target } }); } - this.#settings[key].splice(nodeIndex, 1); + return nodes.toSpliced(nodeIndex, 1); } public refresh() { - const nodes = this.#settings.permissionsRoles; + const nodes = this.#settings.permissionsRoles.slice(); this.#previous = nodes.slice(); if (nodes.length === 0) { @@ -127,6 +134,8 @@ export class PermissionNodeManager implements IBaseManager { const removedIndex = nodes.findIndex((element) => element.id === removedItem); if (removedIndex !== -1) nodes.splice(removedIndex, 1); } + + return nodes; } public onPatch() { diff --git a/src/lib/database/settings/structures/Serializer.ts b/src/lib/database/settings/structures/Serializer.ts index e7944467f79..1f1085f1aca 100644 --- a/src/lib/database/settings/structures/Serializer.ts +++ b/src/lib/database/settings/structures/Serializer.ts @@ -32,7 +32,7 @@ export abstract class Serializer extends AliasPiece { * @param guild The guild given for context in this call */ // eslint-disable-next-line @typescript-eslint/no-unused-vars - public stringify(data: T, _context: Serializer.UpdateContext): string { + public stringify(data: Readonly, _context: Serializer.UpdateContext): string { return String(data); } @@ -181,7 +181,7 @@ export namespace Serializer { export interface SerializerUpdateContext { entry: SchemaKey; - entity: GuildEntity; + entity: Readonly; guild: Guild; t: TFunction; } diff --git a/src/lib/database/settings/structures/collections/GuildSettingsCollection.ts b/src/lib/database/settings/structures/collections/GuildSettingsCollection.ts deleted file mode 100644 index 274da6fd436..00000000000 --- a/src/lib/database/settings/structures/collections/GuildSettingsCollection.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { GuildEntity } from '#lib/database/entities/GuildEntity'; -import { SettingsCollection, type SettingsCollectionCallback } from '#lib/database/settings/base/SettingsCollection'; -import { container } from '@sapphire/framework'; - -export interface GuildSettingsCollectionCallback extends SettingsCollectionCallback {} - -export class GuildSettingsCollection extends SettingsCollection { - public async fetch(key: string): Promise { - const { guilds } = container.db; - const existing = await guilds.findOne({ where: { id: key } }); - if (existing) { - this.set(key, existing); - return existing; - } - - const created = new GuildEntity(); - created.id = key; - this.set(key, created); - return created; - } -} diff --git a/src/lib/moderation/actions/base/RoleModerationAction.ts b/src/lib/moderation/actions/base/RoleModerationAction.ts index 7fecdc7ff44..c62f12a3a6d 100644 --- a/src/lib/moderation/actions/base/RoleModerationAction.ts +++ b/src/lib/moderation/actions/base/RoleModerationAction.ts @@ -1,4 +1,4 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings, writeSettings, writeSettingsTransaction } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { ModerationAction } from '#lib/moderation/actions/base/ModerationAction'; @@ -105,11 +105,10 @@ export abstract class RoleModerationAction { - Reflect.set(settings, this.roleKey, role.id); - return getT(settings.language); - }); + await using trx = await writeSettingsTransaction(guild); + await trx.write({ [this.roleKey]: role.id }).submit(); + const t = getT(settings.language); const manageableChannelCount = guild.channels.cache.reduce( (acc, channel) => (!isThreadChannel(channel) && channel.manageable ? acc + 1 : acc), 0 @@ -380,7 +379,7 @@ export abstract class RoleModerationAction) { if (Array.isArray(options)) return { embeds: options }; if (options instanceof EmbedBuilder) return { embeds: [options] }; return options; @@ -110,7 +121,7 @@ export interface LoggerManagerSendOptions { * Makes the options for the message to send. * @returns The message options to send. */ - makeMessage: () => Awaitable; + makeMessage: (channel: GuildTextBasedChannel) => Awaitable; /** * The function to call when the log operation was aborted before calling * {@linkcode makeMessage}. @@ -118,6 +129,6 @@ export interface LoggerManagerSendOptions { onAbort?: () => void; } -export type LoggerManagerSendMessageOptions = MessageCreateOptions | EmbedBuilder | EmbedBuilder[]; +export type LoggerManagerSendMessageOptions = MessageCreateOptions | EmbedBuilder | EmbedBuilder[] | null; const LogPrefix = getLogPrefix('LoggerManager'); diff --git a/src/lib/moderation/managers/StickyRoleManager.ts b/src/lib/moderation/managers/StickyRoleManager.ts index edb4fdb915c..0325e3602d3 100644 --- a/src/lib/moderation/managers/StickyRoleManager.ts +++ b/src/lib/moderation/managers/StickyRoleManager.ts @@ -1,5 +1,5 @@ import type { StickyRole } from '#lib/database/entities'; -import { readSettings, writeSettings } from '#lib/database/settings'; +import { readSettings, writeSettingsTransaction } from '#lib/database/settings'; import { isNullish } from '@sapphire/utilities'; import type { Guild } from 'discord.js'; @@ -44,94 +44,89 @@ export class StickyRoleManager { } // 3.0.b. Make a clone with the userId and the fixed roles array: - return writeSettings(this.#guild, (settings) => { - const index = settings.stickyRoles.findIndex((entry) => entry.user === userId); - if (index === -1) return []; + await using trx = await writeSettingsTransaction(this.#guild); - const clone: StickyRole = { user: userId, roles }; - settings.stickyRoles[index] = clone; + const index = settings.stickyRoles.findIndex((entry) => entry.user === userId); + if (index === -1) return []; - // 4.0. Return the updated roles: - return clone.roles; - }); + const clone: StickyRole = { user: userId, roles }; + await trx.write({ stickyRoles: settings.stickyRoles.with(index, clone) }).submit(); + + // 4.0. Return the updated roles: + return clone.roles; } - public add(userId: string, roleId: string): Promise { - return writeSettings(this.#guild, (settings) => { - // 0.0 Get all the entries - const entries = settings.stickyRoles; + public async add(userId: string, roleId: string): Promise { + await using trx = await writeSettingsTransaction(this.#guild); - // 1.0. Get the index for the entry: - const index = entries.findIndex((entry) => entry.user === userId); + // 1.0. Get the index for the entry: + const entries = trx.settings.stickyRoles; + const index = entries.findIndex((entry) => entry.user === userId); - // 2.0. If the entry does not exist: - if (index === -1) { - // 3.0.a. Proceed to create a new sticky roles entry: - const entry: StickyRole = { user: userId, roles: [roleId] }; - entries.push(entry); - return entry.roles; - } + // 2.0. If the entry does not exist: + if (index === -1) { + // 3.0.a. Proceed to create a new sticky roles entry: + const entry: StickyRole = { user: userId, roles: [roleId] }; + entries.push(entry); + return entry.roles; + } - // 3.0.b. Otherwise read the previous entry and patch it by adding the role: - const entry = entries[index]; - const roles = [...this.addRole(roleId, entry.roles)]; + // 3. Get the entry and append the role: + const entry = entries[index]; + const roles = [...this.addRole(roleId, entry.roles)]; - // 3.1.b. Otherwise patch it: - entries[index] = { user: entry.user, roles }; + // 4. Write the new roles to the settings: + await trx.write({ stickyRoles: entries.with(index, { user: entry.user, roles }) }).submit(); - // 4.0. Return the updated roles: - return entry.roles; - }); + // 5. Return the updated roles: + return roles; } - public remove(userId: string, roleId: string): Promise { - return writeSettings(this.#guild, (settings) => { - // 0.0 Get all the entries - const entries = settings.stickyRoles; + public async remove(userId: string, roleId: string): Promise { + await using trx = await writeSettingsTransaction(this.#guild); - // 1.0. Get the index for the entry: - const index = entries.findIndex((entry) => entry.user === userId); + const entries = trx.settings.stickyRoles; + // 1.0. Get the index for the entry: + const index = entries.findIndex((entry) => entry.user === userId); - // 1.1. If the index is negative, return empty array, as the entry does not exist: - if (index === -1) return []; + // 1.1. If the index is negative, return empty array, as the entry does not exist: + if (index === -1) return []; - // 2.0. Read the previous entry and patch it by removing the role: - const entry = entries[index]; - const roles = [...this.removeRole(roleId, entry.roles)]; + // 2.0. Read the previous entry and patch it by removing the role: + const entry = entries[index]; + const roles = [...this.removeRole(roleId, entry.roles)]; - if (roles.length === 0) { - // 3.1.a. Then delete the entry from the settings: - entries.splice(index, 1); - } else { - // 3.1.b. Otherwise patch it: - entries[index] = { user: entry.user, roles }; - } + if (roles.length === 0) { + // 3.1.a. Then delete the entry from the settings: + trx.write({ stickyRoles: entries.toSpliced(index, 1) }); + } else { + // 3.1.b. Otherwise patch it: + trx.write({ stickyRoles: entries.with(index, { user: entry.user, roles }) }); + } + await trx.submit(); - // 4.0. Return the updated roles: - return entry.roles; - }); + // 4.0. Return the updated roles: + return entry.roles; } - public clear(userId: string): Promise { - return writeSettings(this.#guild, (settings) => { - // 0.0 Get all the entries - const entries = settings.stickyRoles; + public async clear(userId: string): Promise { + await using trx = await writeSettingsTransaction(this.#guild); - // 1.0. Get the index for the entry: - const index = entries.findIndex((entry) => entry.user === userId); + // 1.0. Get the index for the entry: + const entries = trx.settings.stickyRoles; + const index = entries.findIndex((entry) => entry.user === userId); - // 1.1. If the index is negative, return empty array, as the entry does not exist: - if (index === -1) return []; + // 1.1. If the index is negative, return empty array, as the entry does not exist: + if (index === -1) return []; - // 2.0. Read the previous entry: - const entry = entries[index]; + // 2.0. Read the previous entry: + const entry = entries[index]; - // 3.0. Remove the entry from the settings: - entries.splice(index, 1); + // 3.0. Remove the entry from the settings: + await trx.write({ stickyRoles: entries.toSpliced(index, 1) }).submit(); - // 4.0. Return the previous roles: - return entry.roles; - }); + // 4.0. Return the previous roles: + return entry.roles; } private *addRole(roleId: string, roleIds: readonly string[]) { diff --git a/src/lib/moderation/structures/AutoModerationCommand.ts b/src/lib/moderation/structures/AutoModerationCommand.ts index f5bd48f21ce..b9b79cc2337 100644 --- a/src/lib/moderation/structures/AutoModerationCommand.ts +++ b/src/lib/moderation/structures/AutoModerationCommand.ts @@ -22,7 +22,7 @@ import { CommandOptionsRunTypeEnum, type ApplicationCommandRegistry } from '@sap import { send } from '@sapphire/plugin-editable-commands'; import { applyLocalizedBuilder, createLocalizedChoice, type TFunction } from '@sapphire/plugin-i18next'; import { isNullish, isNullishOrEmpty, isNullishOrZero, type Awaitable } from '@sapphire/utilities'; -import { PermissionFlagsBits, chatInputApplicationCommandMention, strikethrough, type Guild } from 'discord.js'; +import { chatInputApplicationCommandMention, PermissionFlagsBits, strikethrough, type Guild } from 'discord.js'; const Root = LanguageKeys.Commands.AutoModeration; const RootModeration = LanguageKeys.Moderation; @@ -151,7 +151,7 @@ export abstract class AutoModerationCommand extends SkyraSubcommand { if (!isNullish(valuePunishmentThreshold)) pairs.push([this.keyPunishmentThreshold, valuePunishmentThreshold]); if (!isNullish(valuePunishmentThresholdDuration)) pairs.push([this.keyPunishmentThresholdPeriod, valuePunishmentThresholdDuration]); - await writeSettings(interaction.guild, pairs); + await writeSettings(interaction.guild, Object.fromEntries(pairs)); const t = getSupportedUserLanguageT(interaction); const content = t(Root.EditSuccess); @@ -160,7 +160,7 @@ export abstract class AutoModerationCommand extends SkyraSubcommand { public async chatInputRunReset(interaction: AutoModerationCommand.Interaction) { const [key, value] = await this.resetGetKeyValuePair(interaction.guild, interaction.options.getString('key', true) as ResetKey); - await writeSettings(interaction.guild, [[key, value]]); + await writeSettings(interaction.guild, { [key]: value }); const t = getSupportedUserLanguageT(interaction); const content = t(Root.EditSuccess); diff --git a/src/lib/moderation/structures/SetUpModerationCommand.ts b/src/lib/moderation/structures/SetUpModerationCommand.ts index 32a4beff99c..f635a5a66a2 100644 --- a/src/lib/moderation/structures/SetUpModerationCommand.ts +++ b/src/lib/moderation/structures/SetUpModerationCommand.ts @@ -51,7 +51,7 @@ export abstract class SetUpModerationCommand { - const oldValue = deepClone(settings[key.property]); - - switch (action) { - case UpdateType.Set: { - this.t = await set(settings, key, args!); - break; - } - case UpdateType.Remove: { - this.t = await remove(settings, key, args!); - break; - } - case UpdateType.Reset: { - Reflect.set(settings, key.property, key.default); - this.t = getT(settings.language); - break; - } - case UpdateType.Replace: { - Reflect.set(settings, key.property, value); - this.t = getT(settings.language); - break; - } - } - - return [oldValue, false]; - }); + await using trx = await writeSettingsTransaction(this.message.guild); - if (skipped) { - this.errorMessage = this.t(LanguageKeys.Commands.Admin.ConfNochange, { key: key.name }); - } else { - this.oldValue = oldValue; + const oldValue = trx.settings[key.property]; + switch (action) { + case UpdateType.Set: { + trx.write(await set(trx.settings, key, args!)); + break; + } + case UpdateType.Remove: { + trx.write(await remove(trx.settings, key, args!)); + break; + } + case UpdateType.Reset: { + trx.write(reset(key)); + break; + } + case UpdateType.Replace: { + trx.write({ [key.property]: value }); + break; + } } + await trx.submit(); + + this.t = getT(trx.settings.language); + this.oldValue = oldValue; } catch (error) { this.errorMessage = String(error); } diff --git a/src/lib/structures/commands/ChannelConfigurationCommand.ts b/src/lib/structures/commands/ChannelConfigurationCommand.ts index be0bc8322dd..26d84266446 100644 --- a/src/lib/structures/commands/ChannelConfigurationCommand.ts +++ b/src/lib/structures/commands/ChannelConfigurationCommand.ts @@ -1,5 +1,5 @@ import type { GuildSettingsOfType } from '#lib/database'; -import { writeSettings } from '#lib/database/settings'; +import { writeSettingsTransaction } from '#lib/database/settings'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { SkyraCommand } from '#lib/structures/commands/SkyraCommand'; import { PermissionLevels, type GuildMessage, type TypedFT } from '#lib/types'; @@ -27,15 +27,12 @@ export abstract class ChannelConfigurationCommand extends SkyraCommand { public override async messageRun(message: GuildMessage, args: SkyraCommand.Args) { const channel = await args.pick(ChannelConfigurationCommand.hereOrTextChannelResolver); - await writeSettings(message.guild, (settings) => { - // If it's the same value, throw: - if (settings[this.settingsKey] === channel.id) { - this.error(LanguageKeys.Misc.ConfigurationEquals); - } + await using trx = await writeSettingsTransaction(message.guild); + if (trx.settings[this.settingsKey] === channel.id) { + this.error(LanguageKeys.Misc.ConfigurationEquals); + } - // Else set the new value: - Reflect.set(settings, this.settingsKey, channel.id); - }); + await trx.write({ [this.settingsKey]: channel.id }).submit(); const content = args.t(this.responseKey, { channel: channel.toString() }); return send(message, content); diff --git a/src/listeners/guildMessageLog.ts b/src/listeners/guildMessageLog.ts index 245d58511e0..b74aa9cf525 100644 --- a/src/listeners/guildMessageLog.ts +++ b/src/listeners/guildMessageLog.ts @@ -16,7 +16,7 @@ export class UserListener extends Listener { const channel = guild.channels.cache.get(logChannelId) as TextChannel; if (!channel) { - await writeSettings(guild, [[key, null]]); + await writeSettings(guild, { [key]: null }); return; } diff --git a/src/listeners/guilds/channels/channelCreateNotify.ts b/src/listeners/guilds/channels/channelCreateNotify.ts index 7c85b8aa7b3..14c8f82dd28 100644 --- a/src/listeners/guilds/channels/channelCreateNotify.ts +++ b/src/listeners/guilds/channels/channelCreateNotify.ts @@ -1,15 +1,15 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { toPermissionsArray } from '#utils/bits'; import { seconds } from '#utils/common'; import { Colors, LongWidthSpace } from '#utils/constants'; +import { getLogger } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { isNsfwChannel } from '@sapphire/discord.js-utilities'; import { Events, Listener } from '@sapphire/framework'; import type { TFunction } from '@sapphire/plugin-i18next'; -import { isNullish } from '@sapphire/utilities'; import { ChannelType, OverwriteType, @@ -22,26 +22,25 @@ import { @ApplyOptions({ event: Events.ChannelCreate }) export class UserListener extends Listener { - public async run(next: GuildChannel) { - const settings = await readSettings(next.guild); - const channelId = settings.channelsLogsChannelCreate; - if (isNullish(channelId)) return; - - const channel = next.guild.channels.cache.get(channelId) as TextChannel | undefined; - if (channel === undefined) { - await writeSettings(next.guild, [['channelsLogsChannelCreate', null]]); - return; - } - - const t = getT(settings.language); - const changes: string[] = [...this.getChannelInformation(t, next)]; - const embed = new EmbedBuilder() - .setColor(Colors.Green) - .setAuthor({ name: `${next.name} (${next.id})`, iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) - .setDescription(changes.join('\n')) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.ChannelCreate) }) - .setTimestamp(); - await channel.send({ embeds: [embed] }); + public async run(channel: GuildChannel) { + const settings = await readSettings(channel.guild); + await getLogger(channel.guild).send({ + key: 'channelsLogsChannelCreate', + channelId: settings.channelsLogsChannelCreate, + makeMessage: () => { + const t = getT(settings.language); + const changes: string[] = [...this.getChannelInformation(t, channel)]; + return new EmbedBuilder() + .setColor(Colors.Green) + .setAuthor({ + name: `${channel.name} (${channel.id})`, + iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined + }) + .setDescription(changes.join('\n')) + .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.ChannelCreate) }) + .setTimestamp(); + } + }); } private *getChannelInformation(t: TFunction, channel: GuildChannel) { diff --git a/src/listeners/guilds/channels/channelDeleteNotify.ts b/src/listeners/guilds/channels/channelDeleteNotify.ts index 67c16a03f2f..beddca7cf60 100644 --- a/src/listeners/guilds/channels/channelDeleteNotify.ts +++ b/src/listeners/guilds/channels/channelDeleteNotify.ts @@ -1,38 +1,37 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Colors } from '#utils/constants'; +import { getLogger } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { Events, Listener } from '@sapphire/framework'; import type { TFunction } from '@sapphire/plugin-i18next'; -import { isNullish } from '@sapphire/utilities'; import type { CategoryChannel, NewsChannel, TextChannel, VoiceChannel } from 'discord.js'; type GuildBasedChannel = TextChannel | VoiceChannel | CategoryChannel | NewsChannel; @ApplyOptions({ event: Events.ChannelDelete }) export class UserListener extends Listener { - public async run(next: GuildBasedChannel) { - const settings = await readSettings(next.guild); - const channelId = settings.channelsLogsChannelDelete; - if (isNullish(channelId)) return; - - const channel = next.guild.channels.cache.get(channelId) as TextChannel | undefined; - if (channel === undefined) { - await writeSettings(next.guild, [['channelsLogsChannelDelete', null]]); - return; - } - - const t = getT(settings.language); - const changes = [...this.getChannelInformation(t, next)]; - const embed = new EmbedBuilder() - .setColor(Colors.Red) - .setAuthor({ name: `${next.name} (${next.id})`, iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) - .setDescription(changes.join('\n')) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.ChannelDelete) }) - .setTimestamp(); - await channel.send({ embeds: [embed] }); + public async run(channel: GuildBasedChannel) { + const settings = await readSettings(channel.guild); + await getLogger(channel.guild).send({ + key: 'channelsLogsChannelDelete', + channelId: settings.channelsLogsChannelDelete, + makeMessage: () => { + const t = getT(settings.language); + const changes = [...this.getChannelInformation(t, channel)]; + return new EmbedBuilder() + .setColor(Colors.Red) + .setAuthor({ + name: `${channel.name} (${channel.id})`, + iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined + }) + .setDescription(changes.join('\n')) + .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.ChannelDelete) }) + .setTimestamp(); + } + }); } private *getChannelInformation(t: TFunction, channel: GuildBasedChannel) { diff --git a/src/listeners/guilds/channels/channelUpdateNotify.ts b/src/listeners/guilds/channels/channelUpdateNotify.ts index 15dc6431078..0cad7c4e21f 100644 --- a/src/listeners/guilds/channels/channelUpdateNotify.ts +++ b/src/listeners/guilds/channels/channelUpdateNotify.ts @@ -1,22 +1,16 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { toPermissionsArray } from '#utils/bits'; import { seconds } from '#utils/common'; import { differenceBitField, differenceMap } from '#utils/common/comparators'; import { Colors, LongWidthSpace } from '#utils/constants'; +import { getLogger } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; -import { - canSendMessages, - isDMChannel, - isNsfwChannel, - type GuildBasedChannelTypes, - type NonThreadGuildBasedChannelTypes -} from '@sapphire/discord.js-utilities'; +import { isDMChannel, isNsfwChannel, type GuildBasedChannelTypes, type NonThreadGuildBasedChannelTypes } from '@sapphire/discord.js-utilities'; import { Events, Listener } from '@sapphire/framework'; import type { TFunction } from '@sapphire/plugin-i18next'; -import { isNullish } from '@sapphire/utilities'; import { ChannelType, OverwriteType, @@ -36,26 +30,22 @@ export class UserListener extends Listener { if (isDMChannel(next)) return; const settings = await readSettings(next.guild); - const channelId = settings.channelsLogsChannelUpdate; - if (isNullish(channelId)) return; - - const channel = next.guild.channels.cache.get(channelId) as TextChannel | undefined; - if (isNullish(channel) || !canSendMessages(channel)) { - await writeSettings(next.guild, [['channelsLogsChannelUpdate', null]]); - return; - } - - const t = getT(settings.language); - const changes: string[] = [...this.differenceChannel(t, previous as GuildBasedChannelTypes, next as GuildBasedChannelTypes)]; - if (changes.length === 0) return; - - const embed = new EmbedBuilder() - .setColor(Colors.Yellow) - .setAuthor({ name: `${next.name} (${next.id})`, iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) - .setDescription(changes.join('\n')) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.ChannelUpdate) }) - .setTimestamp(); - await channel.send({ embeds: [embed] }); + await getLogger(next.guild).send({ + key: 'channelsLogsChannelUpdate', + channelId: settings.channelsLogsChannelUpdate, + makeMessage: () => { + const t = getT(settings.language); + const changes: string[] = [...this.differenceChannel(t, previous as GuildBasedChannelTypes, next as GuildBasedChannelTypes)]; + if (changes.length === 0) return null; + + return new EmbedBuilder() + .setColor(Colors.Yellow) + .setAuthor({ name: `${next.name} (${next.id})`, iconURL: next.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) + .setDescription(changes.join('\n')) + .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.ChannelUpdate) }) + .setTimestamp(); + } + }); } private *differenceChannel(t: TFunction, previous: GuildBasedChannelTypes, next: GuildBasedChannelTypes) { diff --git a/src/listeners/guilds/emojis/emojiCreateNotify.ts b/src/listeners/guilds/emojis/emojiCreateNotify.ts index 3929d239c0c..dbc5e5488f1 100644 --- a/src/listeners/guilds/emojis/emojiCreateNotify.ts +++ b/src/listeners/guilds/emojis/emojiCreateNotify.ts @@ -1,37 +1,33 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Colors } from '#utils/constants'; +import { getLogger } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { Events, Listener } from '@sapphire/framework'; import type { TFunction } from '@sapphire/plugin-i18next'; -import { isNullish } from '@sapphire/utilities'; -import type { GuildEmoji, TextChannel } from 'discord.js'; +import type { GuildEmoji } from 'discord.js'; @ApplyOptions({ event: Events.GuildEmojiCreate }) export class UserListener extends Listener { public async run(next: GuildEmoji) { const settings = await readSettings(next.guild); - const channelId = settings.channelsLogsEmojiCreate; - if (isNullish(channelId)) return; - - const channel = next.guild.channels.cache.get(channelId) as TextChannel | undefined; - if (channel === undefined) { - await writeSettings(next.guild, [['channelsLogsEmojiCreate', null]]); - return; - } - - const t = getT(settings.language); - const changes: string[] = [...this.getEmojiInformation(t, next)]; - const embed = new EmbedBuilder() - .setColor(Colors.Green) - .setThumbnail(next.imageURL({ size: 256 })) - .setAuthor({ name: `${next.name} (${next.id})`, iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) - .setDescription(changes.join('\n')) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.EmojiCreate) }) - .setTimestamp(); - await channel.send({ embeds: [embed] }); + await getLogger(next.guild).send({ + key: 'channelsLogsEmojiCreate', + channelId: settings.channelsLogsEmojiCreate, + makeMessage: () => { + const t = getT(settings.language); + const changes: string[] = [...this.getEmojiInformation(t, next)]; + return new EmbedBuilder() + .setColor(Colors.Green) + .setThumbnail(next.imageURL({ size: 256 })) + .setAuthor({ name: `${next.name} (${next.id})`, iconURL: next.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) + .setDescription(changes.join('\n')) + .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.EmojiCreate) }) + .setTimestamp(); + } + }); } private *getEmojiInformation(t: TFunction, next: GuildEmoji) { diff --git a/src/listeners/guilds/emojis/emojiDeleteNotify.ts b/src/listeners/guilds/emojis/emojiDeleteNotify.ts index f663357b5e5..affbab4bf2b 100644 --- a/src/listeners/guilds/emojis/emojiDeleteNotify.ts +++ b/src/listeners/guilds/emojis/emojiDeleteNotify.ts @@ -1,33 +1,29 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Colors } from '#utils/constants'; +import { getLogger } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { Events, Listener } from '@sapphire/framework'; -import { isNullish } from '@sapphire/utilities'; -import type { GuildEmoji, TextChannel } from 'discord.js'; +import type { GuildEmoji } from 'discord.js'; @ApplyOptions({ event: Events.GuildEmojiDelete }) export class UserListener extends Listener { public async run(next: GuildEmoji) { const settings = await readSettings(next.guild); - const channelId = settings.channelsLogsEmojiDelete; - if (isNullish(channelId)) return; - - const channel = next.guild.channels.cache.get(channelId) as TextChannel | undefined; - if (channel === undefined) { - await writeSettings(next.guild, [['channelsLogsEmojiDelete', null]]); - return; - } - - const t = getT(settings.language); - const embed = new EmbedBuilder() - .setColor(Colors.Red) - .setThumbnail(next.imageURL({ size: 256 })) - .setAuthor({ name: `${next.name} (${next.id})`, iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.EmojiDelete) }) - .setTimestamp(); - await channel.send({ embeds: [embed] }); + await getLogger(next.guild).send({ + key: 'channelsLogsEmojiDelete', + channelId: settings.channelsLogsEmojiDelete, + makeMessage: () => { + const t = getT(settings.language); + return new EmbedBuilder() + .setColor(Colors.Red) + .setThumbnail(next.imageURL({ size: 256 })) + .setAuthor({ name: `${next.name} (${next.id})`, iconURL: next.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) + .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.EmojiDelete) }) + .setTimestamp(); + } + }); } } diff --git a/src/listeners/guilds/emojis/emojiUpdateNotify.ts b/src/listeners/guilds/emojis/emojiUpdateNotify.ts index f7c4cec149e..d43ad48149b 100644 --- a/src/listeners/guilds/emojis/emojiUpdateNotify.ts +++ b/src/listeners/guilds/emojis/emojiUpdateNotify.ts @@ -1,40 +1,36 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { differenceMap } from '#utils/common/comparators'; import { Colors } from '#utils/constants'; +import { getLogger } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { Events, Listener } from '@sapphire/framework'; import type { TFunction } from '@sapphire/plugin-i18next'; -import { isNullish } from '@sapphire/utilities'; -import type { GuildEmoji, TextChannel } from 'discord.js'; +import type { GuildEmoji } from 'discord.js'; @ApplyOptions({ event: Events.GuildEmojiUpdate }) export class UserListener extends Listener { public async run(previous: GuildEmoji, next: GuildEmoji) { const settings = await readSettings(next.guild); - const channelId = settings.channelsLogsEmojiUpdate; - if (isNullish(channelId)) return; + await getLogger(next.guild).send({ + key: 'channelsLogsEmojiUpdate', + channelId: settings.channelsLogsEmojiUpdate, + makeMessage: () => { + const t = getT(settings.language); + const changes: string[] = [...this.differenceEmoji(t, previous, next)]; + if (changes.length === 0) return null; - const channel = next.guild.channels.cache.get(channelId) as TextChannel | undefined; - if (channel === undefined) { - await writeSettings(next.guild, [['channelsLogsEmojiUpdate', null]]); - return; - } - - const t = getT(settings.language); - const changes: string[] = [...this.differenceEmoji(t, previous, next)]; - if (changes.length === 0) return; - - const embed = new EmbedBuilder() - .setColor(Colors.Yellow) - .setThumbnail(next.imageURL({ size: 256 })) - .setAuthor({ name: `${next.name} (${next.id})`, iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) - .setDescription(changes.join('\n')) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.EmojiUpdate) }) - .setTimestamp(); - await channel.send({ embeds: [embed] }); + return new EmbedBuilder() + .setColor(Colors.Yellow) + .setThumbnail(next.imageURL({ size: 256 })) + .setAuthor({ name: `${next.name} (${next.id})`, iconURL: next.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) + .setDescription(changes.join('\n')) + .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.EmojiUpdate) }) + .setTimestamp(); + } + }); } private *differenceEmoji(t: TFunction, previous: GuildEmoji, next: GuildEmoji) { diff --git a/src/listeners/guilds/guildUpdateNotify.ts b/src/listeners/guilds/guildUpdateNotify.ts index 1819e30e514..52aca8ca4ea 100644 --- a/src/listeners/guilds/guildUpdateNotify.ts +++ b/src/listeners/guilds/guildUpdateNotify.ts @@ -1,14 +1,14 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { toChannelsArray } from '#utils/bits'; import { differenceArray, differenceBitField, seconds } from '#utils/common'; import { Colors } from '#utils/constants'; +import { getLogger } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { Events, Listener } from '@sapphire/framework'; import type { TFunction } from '@sapphire/plugin-i18next'; -import { isNullish } from '@sapphire/utilities'; import { GuildMFALevel, type Guild, @@ -17,8 +17,7 @@ import { type GuildFeature, type GuildPremiumTier, type GuildVerificationLevel, - type SystemChannelFlagsBitField, - type TextChannel + type SystemChannelFlagsBitField } from 'discord.js'; type ChannelFlags = Readonly; @@ -28,26 +27,22 @@ type Features = readonly `${GuildFeature}`[]; export class UserListener extends Listener { public async run(previous: Guild, next: Guild) { const settings = await readSettings(next); - const channelId = settings.channelsLogsServerUpdate; - if (isNullish(channelId)) return; - - const channel = next.channels.cache.get(channelId) as TextChannel | undefined; - if (channel === undefined) { - await writeSettings(next, [['channelsLogsServerUpdate', null]]); - return; - } - - const t = getT(settings.language); - const changes: string[] = [...this.differenceGuild(t, previous, next)]; - if (changes.length === 0) return; - - const embed = new EmbedBuilder() - .setColor(Colors.Yellow) - .setAuthor({ name: `${next.name} (${next.id})`, iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) - .setDescription(changes.join('\n')) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.ServerUpdate) }) - .setTimestamp(); - await channel.send({ embeds: [embed] }); + await getLogger(next).send({ + key: 'channelsLogsServerUpdate', + channelId: settings.channelsLogsServerUpdate, + makeMessage: () => { + const t = getT(settings.language); + const changes: string[] = [...this.differenceGuild(t, previous, next)]; + if (changes.length === 0) return null; + + return new EmbedBuilder() + .setColor(Colors.Yellow) + .setAuthor({ name: `${next.name} (${next.id})`, iconURL: next.iconURL({ size: 64, extension: 'png' }) ?? undefined }) + .setDescription(changes.join('\n')) + .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.ServerUpdate) }) + .setTimestamp(); + } + }); } private *differenceGuild(t: TFunction, previous: Guild, next: Guild) { diff --git a/src/listeners/guilds/members/guildMemberAdd.ts b/src/listeners/guilds/members/guildMemberAdd.ts index 68b33237afb..35fa02ca366 100644 --- a/src/listeners/guilds/members/guildMemberAdd.ts +++ b/src/listeners/guilds/members/guildMemberAdd.ts @@ -46,7 +46,7 @@ export class UserListener extends Listener { const { guild } = member; const role = guild.roles.cache.get(mutedRoleId); if (isNullish(role)) { - await writeSettings(member, [['rolesMuted', null]]); + await writeSettings(member, { rolesMuted: null }); } else { const result = await toErrorCodeResult(member.roles.add(role)); await result.inspectErrAsync((code) => this.#handleMutedMemberAddRoleErr(guild, code)); @@ -59,7 +59,7 @@ export class UserListener extends Listener { // The role was deleted, remove it from the settings: if (code === RESTJSONErrorCodes.UnknownRole) { - await writeSettings(guild, [['rolesMuted', null]]); + await writeSettings(guild, { rolesMuted: null }); return; } diff --git a/src/listeners/guilds/members/notMutedMemberAddInitialRole.ts b/src/listeners/guilds/members/notMutedMemberAddInitialRole.ts index cf5e777485d..06a43d5fb6e 100644 --- a/src/listeners/guilds/members/notMutedMemberAddInitialRole.ts +++ b/src/listeners/guilds/members/notMutedMemberAddInitialRole.ts @@ -28,7 +28,8 @@ export class UserListener extends Listener { : member.user.bot ? 'rolesInitialBots' : 'rolesInitialHumans'; - return writeSettings(member, [[key, null]]); + await writeSettings(member, { [key]: null }); + return; } // In any other case, log the error as unexpected: diff --git a/src/listeners/guilds/rawGuildCreateMemberFetch.ts b/src/listeners/guilds/rawGuildCreateMemberFetch.ts index 98cf23cc532..53fcb2bf674 100644 --- a/src/listeners/guilds/rawGuildCreateMemberFetch.ts +++ b/src/listeners/guilds/rawGuildCreateMemberFetch.ts @@ -1,10 +1,10 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Listener } from '@sapphire/framework'; -import { GatewayDispatchEvents, type GatewayGuildCreateDispatch } from 'discord.js'; +import { GatewayDispatchEvents, type GatewayGuildCreateDispatchData } from 'discord.js'; @ApplyOptions({ event: GatewayDispatchEvents.GuildCreate, emitter: 'ws' }) export class UserListener extends Listener { - public run(data: GatewayGuildCreateDispatch['d'], shardId: number) { + public run(data: GatewayGuildCreateDispatchData, shardId: number) { this.container.client.guildMemberFetchQueue.add(shardId, data.id); } } diff --git a/src/listeners/guilds/rawGuildDelete.ts b/src/listeners/guilds/rawGuildDelete.ts index c2020563e57..73e014c4c7a 100644 --- a/src/listeners/guilds/rawGuildDelete.ts +++ b/src/listeners/guilds/rawGuildDelete.ts @@ -1,12 +1,13 @@ +import { deleteSettingsCached } from '#lib/database'; import { ApplyOptions } from '@sapphire/decorators'; import { Listener } from '@sapphire/framework'; -import { GatewayDispatchEvents, type GatewayGuildDeleteDispatch } from 'discord.js'; +import { GatewayDispatchEvents, type GatewayGuildDeleteDispatchData } from 'discord.js'; @ApplyOptions({ event: GatewayDispatchEvents.GuildDelete, emitter: 'ws' }) export class UserListener extends Listener { - public run(data: GatewayGuildDeleteDispatch['d']) { + public run(data: GatewayGuildDeleteDispatchData) { if (data.unavailable) return; - this.container.settings.guilds.delete(data.id); + deleteSettingsCached(data.id); } } diff --git a/src/listeners/guilds/rawGuildDeleteMemberFetch.ts b/src/listeners/guilds/rawGuildDeleteMemberFetch.ts index dbd2ea29f88..d6c329513bf 100644 --- a/src/listeners/guilds/rawGuildDeleteMemberFetch.ts +++ b/src/listeners/guilds/rawGuildDeleteMemberFetch.ts @@ -1,10 +1,10 @@ import { ApplyOptions } from '@sapphire/decorators'; import { Listener } from '@sapphire/framework'; -import { GatewayDispatchEvents, type GatewayGuildDeleteDispatch } from 'discord.js'; +import { GatewayDispatchEvents, type GatewayGuildDeleteDispatchData } from 'discord.js'; @ApplyOptions({ event: GatewayDispatchEvents.GuildDelete, emitter: 'ws' }) export class UserListener extends Listener { - public run(data: GatewayGuildDeleteDispatch['d'], shardId: number) { + public run(data: GatewayGuildDeleteDispatchData, shardId: number) { this.container.client.guildMemberFetchQueue.remove(shardId, data.id); } } diff --git a/src/listeners/guilds/roles/roleCreateNotify.ts b/src/listeners/guilds/roles/roleCreateNotify.ts index 098c49f7091..5399f184f1f 100644 --- a/src/listeners/guilds/roles/roleCreateNotify.ts +++ b/src/listeners/guilds/roles/roleCreateNotify.ts @@ -19,7 +19,7 @@ export class UserListener extends Listener { const channel = next.guild.channels.cache.get(channelId) as TextChannel | undefined; if (channel === undefined) { - await writeSettings(next, [['channelsLogsRoleCreate', null]]); + await writeSettings(next, { channelsLogsRoleCreate: null }); return; } diff --git a/src/listeners/guilds/roles/roleDelete.ts b/src/listeners/guilds/roles/roleDelete.ts index 6a13070599c..766fed723d0 100644 --- a/src/listeners/guilds/roles/roleDelete.ts +++ b/src/listeners/guilds/roles/roleDelete.ts @@ -1,54 +1,57 @@ -import { writeSettings } from '#lib/database'; +import { writeSettingsTransaction, type StickyRole, type UniqueRoleSet } from '#lib/database'; import { Listener } from '@sapphire/framework'; +import { filter, map, toArray } from '@sapphire/iterator-utilities'; import type { Role } from 'discord.js'; export class UserListener extends Listener { - public run(role: Role) { + public async run(role: Role) { if (!role.guild.available) return; - return writeSettings(role, (settings) => { - for (const stickyRole of settings.stickyRoles) { - stickyRole.roles = stickyRole.roles.filter((srr) => srr !== role.id); - } - - settings.stickyRoles = settings.stickyRoles.filter((sr) => Boolean(sr.roles.length)); - - for (const uniqueRoleSet of settings.rolesUniqueRoleSets) { - uniqueRoleSet.roles = uniqueRoleSet.roles.filter((urs) => urs !== role.id); - } - - settings.rolesUniqueRoleSets = settings.rolesUniqueRoleSets.filter((sr) => Boolean(sr.roles.length)); - - settings.reactionRoles = settings.reactionRoles.filter((rr) => rr.role !== role.id); - settings.rolesModerator = settings.rolesModerator.filter((rm) => rm !== role.id); - settings.rolesAdmin = settings.rolesAdmin.filter((rm) => rm !== role.id); - settings.rolesPublic = settings.rolesPublic.filter((rm) => rm !== role.id); - - settings.selfmodAttachmentsIgnoredRoles = settings.selfmodAttachmentsIgnoredRoles.filter((rm) => rm !== role.id); - settings.selfmodCapitalsIgnoredRoles = settings.selfmodCapitalsIgnoredRoles.filter((rm) => rm !== role.id); - settings.selfmodLinksIgnoredRoles = settings.selfmodLinksIgnoredRoles.filter((rm) => rm !== role.id); - settings.selfmodMessagesIgnoredRoles = settings.selfmodMessagesIgnoredRoles.filter((rm) => rm !== role.id); - settings.selfmodNewlinesIgnoredRoles = settings.selfmodNewlinesIgnoredRoles.filter((rm) => rm !== role.id); - settings.selfmodInvitesIgnoredRoles = settings.selfmodInvitesIgnoredRoles.filter((rm) => rm !== role.id); - settings.selfmodFilterIgnoredRoles = settings.selfmodFilterIgnoredRoles.filter((rm) => rm !== role.id); - settings.selfmodReactionsIgnoredRoles = settings.selfmodReactionsIgnoredRoles.filter((rm) => rm !== role.id); - - if (settings.rolesInitial === role.id) settings.rolesInitial = null; - if (settings.rolesInitialHumans === role.id) settings.rolesInitialHumans = null; - if (settings.rolesInitialBots === role.id) settings.rolesInitialBots = null; - if (settings.rolesMuted === role.id) settings.rolesMuted = null; - - if (settings.rolesRestrictedReaction === role.id) settings.rolesRestrictedReaction = null; - if (settings.rolesRestrictedEmbed === role.id) settings.rolesRestrictedEmbed = null; - - if (settings.rolesRestrictedEmoji === role.id) settings.rolesRestrictedEmoji = null; - if (settings.rolesRestrictedAttachment === role.id) settings.rolesRestrictedAttachment = null; + await using trx = await writeSettingsTransaction(role); + + trx.write({ stickyRoles: this.#filterStickyRoles(trx.settings.stickyRoles, role) }); + trx.write({ rolesUniqueRoleSets: this.#filterUniqueRoleSets(trx.settings.rolesUniqueRoleSets, role) }); + + trx.write({ reactionRoles: trx.settings.reactionRoles.filter((rr) => rr.role !== role.id) }); + trx.write({ rolesModerator: trx.settings.rolesModerator.filter((rm) => rm !== role.id) }); + trx.write({ rolesAdmin: trx.settings.rolesAdmin.filter((rm) => rm !== role.id) }); + trx.write({ rolesPublic: trx.settings.rolesPublic.filter((rm) => rm !== role.id) }); + + trx.write({ selfmodAttachmentsIgnoredRoles: trx.settings.selfmodAttachmentsIgnoredRoles.filter((rm) => rm !== role.id) }); + trx.write({ selfmodCapitalsIgnoredRoles: trx.settings.selfmodCapitalsIgnoredRoles.filter((rm) => rm !== role.id) }); + trx.write({ selfmodLinksIgnoredRoles: trx.settings.selfmodLinksIgnoredRoles.filter((rm) => rm !== role.id) }); + trx.write({ selfmodMessagesIgnoredRoles: trx.settings.selfmodMessagesIgnoredRoles.filter((rm) => rm !== role.id) }); + trx.write({ selfmodNewlinesIgnoredRoles: trx.settings.selfmodNewlinesIgnoredRoles.filter((rm) => rm !== role.id) }); + trx.write({ selfmodInvitesIgnoredRoles: trx.settings.selfmodInvitesIgnoredRoles.filter((rm) => rm !== role.id) }); + trx.write({ selfmodFilterIgnoredRoles: trx.settings.selfmodFilterIgnoredRoles.filter((rm) => rm !== role.id) }); + trx.write({ selfmodReactionsIgnoredRoles: trx.settings.selfmodReactionsIgnoredRoles.filter((rm) => rm !== role.id) }); + + if (trx.settings.rolesInitial === role.id) trx.write({ rolesInitial: null }); + if (trx.settings.rolesInitialHumans === role.id) trx.write({ rolesInitialHumans: null }); + if (trx.settings.rolesInitialBots === role.id) trx.write({ rolesInitialBots: null }); + if (trx.settings.rolesMuted === role.id) trx.write({ rolesMuted: null }); + if (trx.settings.rolesRestrictedReaction === role.id) trx.write({ rolesRestrictedReaction: null }); + if (trx.settings.rolesRestrictedEmbed === role.id) trx.write({ rolesRestrictedEmbed: null }); + if (trx.settings.rolesRestrictedEmoji === role.id) trx.write({ rolesRestrictedEmoji: null }); + if (trx.settings.rolesRestrictedAttachment === role.id) trx.write({ rolesRestrictedAttachment: null }); + if (trx.settings.rolesRestrictedVoice === role.id) trx.write({ rolesRestrictedVoice: null }); + + if (trx.settings.permissionNodes.has(role.id)) { + trx.write({ permissionsRoles: trx.settings.permissionNodes.refresh() }); + } + + await trx.submit(); + } - if (settings.rolesRestrictedVoice === role.id) settings.rolesRestrictedVoice = null; + #filterStickyRoles(roles: readonly StickyRole[], role: Role) { + const mapped = map(roles, (entry): StickyRole => ({ user: entry.user, roles: entry.roles.filter((srr) => srr !== role.id) })); + const filtered = filter(mapped, (entry) => entry.roles.length > 0); + return toArray(filtered); + } - if (this.container.settings.guilds.get(role.guild.id)?.permissionNodes.has(role.id)) { - settings.permissionNodes.refresh(); - } - }); + #filterUniqueRoleSets(roles: readonly UniqueRoleSet[], role: Role) { + const mapped = map(roles, (entry): UniqueRoleSet => ({ name: entry.name, roles: entry.roles.filter((urs) => urs !== role.id) })); + const filtered = filter(mapped, (entry) => entry.roles.length > 0); + return toArray(filtered); } } diff --git a/src/listeners/guilds/roles/roleDeleteNotify.ts b/src/listeners/guilds/roles/roleDeleteNotify.ts index d91512a794b..a52756fa24f 100644 --- a/src/listeners/guilds/roles/roleDeleteNotify.ts +++ b/src/listeners/guilds/roles/roleDeleteNotify.ts @@ -1,32 +1,28 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { Colors } from '#utils/constants'; +import { getLogger } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { Events, Listener } from '@sapphire/framework'; -import { isNullish } from '@sapphire/utilities'; -import type { Role, TextChannel } from 'discord.js'; +import type { Role } from 'discord.js'; @ApplyOptions({ event: Events.GuildRoleDelete }) export class UserListener extends Listener { public async run(role: Role) { const settings = await readSettings(role); - const channelId = settings.channelsLogsRoleDelete; - if (isNullish(channelId)) return; - - const channel = role.guild.channels.cache.get(channelId) as TextChannel | undefined; - if (channel === undefined) { - await writeSettings(role, [['channelsLogsRoleDelete', null]]); - return; - } - - const t = getT(settings.language); - const embed = new EmbedBuilder() - .setColor(Colors.Red) - .setAuthor({ name: `${role.name} (${role.id})`, iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.RoleDelete) }) - .setTimestamp(); - await channel.send({ embeds: [embed] }); + await getLogger(role.guild).send({ + key: 'channelsLogsRoleDelete', + channelId: settings.channelsLogsRoleDelete, + makeMessage: () => { + const t = getT(settings.language); + return new EmbedBuilder() + .setColor(Colors.Red) + .setAuthor({ name: `${role.name} (${role.id})`, iconURL: role.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) + .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.RoleDelete) }) + .setTimestamp(); + } + }); } } diff --git a/src/listeners/guilds/roles/roleUpdate.ts b/src/listeners/guilds/roles/roleUpdate.ts index 6f223e24994..7b87b859f53 100644 --- a/src/listeners/guilds/roles/roleUpdate.ts +++ b/src/listeners/guilds/roles/roleUpdate.ts @@ -1,12 +1,15 @@ -import { writeSettings } from '#lib/database'; +import { readSettingsCached, writeSettings } from '#lib/database'; import { Listener } from '@sapphire/framework'; import type { Role } from 'discord.js'; export class UserListener extends Listener { - public run(previous: Role, next: Role) { + public async run(previous: Role, next: Role) { if (!next.guild.available) return; if (previous.position === next.position) return; - if (!this.container.settings.guilds.get(next.guild.id)?.permissionNodes.has(next.id)) return; - return writeSettings(next, (settings) => settings.adders.refresh()); + + const settings = readSettingsCached(next); + if (!settings?.permissionNodes.has(next.id)) return; + + await writeSettings(next, { permissionsRoles: settings.permissionNodes.refresh() }); } } diff --git a/src/listeners/guilds/roles/roleUpdateNotify.ts b/src/listeners/guilds/roles/roleUpdateNotify.ts index ad5ad6d0550..e45311df9b7 100644 --- a/src/listeners/guilds/roles/roleUpdateNotify.ts +++ b/src/listeners/guilds/roles/roleUpdateNotify.ts @@ -1,40 +1,36 @@ -import { readSettings, writeSettings } from '#lib/database'; +import { readSettings } from '#lib/database'; import { getT } from '#lib/i18n'; import { LanguageKeys } from '#lib/i18n/languageKeys'; import { toPermissionsArray } from '#utils/bits'; import { differenceBitField } from '#utils/common/comparators'; import { Colors } from '#utils/constants'; +import { getLogger } from '#utils/functions'; import { EmbedBuilder } from '@discordjs/builders'; import { ApplyOptions } from '@sapphire/decorators'; import { Events, Listener } from '@sapphire/framework'; import type { TFunction } from '@sapphire/plugin-i18next'; -import { isNullish } from '@sapphire/utilities'; -import type { Role, TextChannel } from 'discord.js'; +import type { Role } from 'discord.js'; @ApplyOptions({ event: Events.GuildRoleUpdate }) export class UserListener extends Listener { public async run(previous: Role, next: Role) { const settings = await readSettings(next); - const channelId = settings.channelsLogsRoleUpdate; - if (isNullish(channelId)) return; + await getLogger(next.guild).send({ + key: 'channelsLogsRoleUpdate', + channelId: settings.channelsLogsRoleUpdate, + makeMessage: () => { + const t = getT(settings.language); + const changes: string[] = [...this.differenceRole(t, previous, next)]; + if (changes.length === 0) return null; - const channel = next.guild.channels.cache.get(channelId) as TextChannel | undefined; - if (channel === undefined) { - await writeSettings(next, [['channelsLogsRoleUpdate', null]]); - return; - } - - const t = getT(settings.language); - const changes: string[] = [...this.differenceRole(t, previous, next)]; - if (changes.length === 0) return; - - const embed = new EmbedBuilder() - .setColor(Colors.Yellow) - .setAuthor({ name: `${next.name} (${next.id})`, iconURL: channel.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) - .setDescription(changes.join('\n')) - .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.RoleUpdate) }) - .setTimestamp(); - await channel.send({ embeds: [embed] }); + return new EmbedBuilder() + .setColor(Colors.Yellow) + .setAuthor({ name: `${next.name} (${next.id})`, iconURL: next.guild.iconURL({ size: 64, extension: 'png' }) ?? undefined }) + .setDescription(changes.join('\n')) + .setFooter({ text: t(LanguageKeys.Events.Guilds.Logs.RoleUpdate) }) + .setTimestamp(); + } + }); } private *differenceRole(t: TFunction, previous: Role, next: Role) { diff --git a/src/listeners/moderation/moderationEntryAdd.ts b/src/listeners/moderation/moderationEntryAdd.ts index 6b81a6aa12f..a4e06a1a864 100644 --- a/src/listeners/moderation/moderationEntryAdd.ts +++ b/src/listeners/moderation/moderationEntryAdd.ts @@ -25,7 +25,7 @@ export class UserListener extends Listener { try { await resolveOnErrorCodes(channel.send(options), RESTJSONErrorCodes.MissingAccess, RESTJSONErrorCodes.MissingPermissions); } catch (error) { - await writeSettings(entry.guild, [['channelsLogsModeration', null]]); + await writeSettings(entry.guild, { channelsLogsModeration: null }); } } diff --git a/src/listeners/moderation/moderationEntryEdit.ts b/src/listeners/moderation/moderationEntryEdit.ts index 2e8107b6cd3..ccd35b4e6ec 100644 --- a/src/listeners/moderation/moderationEntryEdit.ts +++ b/src/listeners/moderation/moderationEntryEdit.ts @@ -55,7 +55,7 @@ export class UserListener extends Listener { RESTJSONErrorCodes.MissingPermissions ); } catch (error) { - await writeSettings(entry.guild, [['channelsLogsModeration', null]]); + await writeSettings(entry.guild, { channelsLogsModeration: null }); } } diff --git a/src/routes/guilds/guild/settings.ts b/src/routes/guilds/guild/settings.ts index 366e3f11b89..be2c5d01ef4 100644 --- a/src/routes/guilds/guild/settings.ts +++ b/src/routes/guilds/guild/settings.ts @@ -3,7 +3,7 @@ import { configurableKeys, isSchemaKey, readSettings, - writeSettings, + writeSettingsTransaction, type GuildDataKey, type GuildDataValue, type GuildEntity, @@ -54,17 +54,11 @@ export class UserRoute extends Route { const entries = requestBody.data; try { - const settings = await writeSettings(guild, async (settings) => { - const pairs = await this.validateAll(settings, guild, entries); + await using trx = await writeSettingsTransaction(guild); + const data = await this.validateAll(trx.settings, guild, entries); + await trx.write(Object.fromEntries(data)).submit(); - for (const [key, value] of pairs) { - Reflect.set(settings, key, value); - } - - return settings.toJSON(); - }); - - return response.status(HttpCodes.OK).json(settings); + return response.status(HttpCodes.OK).json(trx.settings.toJSON()); } catch (errors) { return response.status(HttpCodes.BadRequest).json(errors); } @@ -95,7 +89,7 @@ export class UserRoute extends Route { return Promise.all(value.map((value) => serializer.isValid(value, ctx))); } - private async validateAll(entity: GuildEntity, guild: Guild, pairs: readonly [GuildDataKey, GuildDataValue][]) { + private async validateAll(entity: Readonly, guild: Guild, pairs: readonly [GuildDataKey, GuildDataValue][]) { const context: PartialSerializerUpdateContext = { entity, guild, diff --git a/src/serializers/word.ts b/src/serializers/word.ts index a3fb7176359..9132705c668 100644 --- a/src/serializers/word.ts +++ b/src/serializers/word.ts @@ -19,7 +19,7 @@ export class UserSerializer extends Serializer { return value === word && this.minOrMax(value, value.length, context).isOk(); } - private async hasWord(settings: GuildEntity, content: string) { + private async hasWord(settings: Readonly, content: string) { const words = settings.selfmodFilterRaw; if (words.includes(content)) return true;