diff --git a/src/commands/Moderation/lockdown.ts b/src/commands/Moderation/lockdown.ts index f78b78d9da4..9759cff2936 100644 --- a/src/commands/Moderation/lockdown.ts +++ b/src/commands/Moderation/lockdown.ts @@ -1,129 +1,376 @@ import { LanguageKeys } from '#lib/i18n/languageKeys'; -import { LockdownManager, SkyraSubcommand } from '#lib/structures'; +import { getSupportedUserLanguageT } from '#lib/i18n/translate'; +import { LockdownType, type LockdownData } from '#lib/schedule'; +import { SkyraCommand } from '#lib/structures'; import { PermissionLevels, type GuildMessage } from '#lib/types'; -import { clearAccurateTimeout, setAccurateTimeout } from '#utils/Timers'; -import { floatPromise } from '#utils/common'; -import { assertNonThread, getSecurity } from '#utils/functions'; +import { PermissionsBits } from '#utils/bits'; +import { months, toErrorCodeResult } from '#utils/common'; +import { getCodeStyle, getLogPrefix } from '#utils/functions'; +import { resolveTimeSpan } from '#utils/resolvers'; +import { getTag } from '#utils/util'; import { ApplyOptions } from '@sapphire/decorators'; -import { canSendMessages, type NonThreadGuildTextBasedChannelTypes } from '@sapphire/discord.js-utilities'; -import { CommandOptionsRunTypeEnum } from '@sapphire/framework'; +import { ApplicationCommandRegistry, CommandOptionsRunTypeEnum, ok } from '@sapphire/framework'; import { send } from '@sapphire/plugin-editable-commands'; -import type { TFunction } from '@sapphire/plugin-i18next'; -import { PermissionFlagsBits, type Role } from 'discord.js'; +import { applyLocalizedBuilder, createLocalizedChoice, type TFunction } from '@sapphire/plugin-i18next'; +import { Time } from '@sapphire/time-utilities'; +import { isNullish, isNullishOrZero } from '@sapphire/utilities'; +import { + CategoryChannel, + ChannelType, + ForumChannel, + MediaChannel, + MessageFlags, + PermissionFlagsBits, + RESTJSONErrorCodes, + channelMention, + chatInputApplicationCommandMention, + type AnyThreadChannel, + type GuildTextBasedChannel, + type Role, + type User, + type VoiceBasedChannel +} from 'discord.js'; -@ApplyOptions({ +const Root = LanguageKeys.Commands.Lockdown; +const LockdownPermissions = PermissionFlagsBits.SendMessages | PermissionFlagsBits.SendMessagesInThreads; +const LockdownTextPermissions = + PermissionFlagsBits.SendMessages | + PermissionFlagsBits.SendMessagesInThreads | + PermissionFlagsBits.CreatePublicThreads | + PermissionFlagsBits.CreatePrivateThreads; +const LockdownVoicePermissions = PermissionFlagsBits.Connect; +const LockdownMixedPermissions = LockdownTextPermissions | LockdownVoicePermissions; + +@ApplyOptions({ aliases: ['lock', 'unlock'], description: LanguageKeys.Commands.Moderation.LockdownDescription, detailedDescription: LanguageKeys.Commands.Moderation.LockdownExtended, permissionLevel: PermissionLevels.Moderator, requiredClientPermissions: [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageRoles], - runIn: [CommandOptionsRunTypeEnum.GuildAny], - subcommands: [ - { name: 'lock', messageRun: 'lock' }, - { name: 'unlock', messageRun: 'unlock' }, - { name: 'auto', messageRun: 'auto', default: true } - ] + runIn: [CommandOptionsRunTypeEnum.GuildAny] }) -export class UserCommand extends SkyraSubcommand { - public override messageRun(message: GuildMessage, args: SkyraSubcommand.Args, context: SkyraSubcommand.RunContext) { - if (context.commandName === 'lock') return this.lock(message, args); - if (context.commandName === 'unlock') return this.unlock(message, args); - return super.messageRun(message, args, context); +export class UserCommand extends SkyraCommand { + public override messageRun(message: GuildMessage, args: SkyraCommand.Args) { + const content = args.t(LanguageKeys.Commands.Shared.DeprecatedMessage, { + command: chatInputApplicationCommandMention(this.name, this.getGlobalCommandId()) + }); + return send(message, { content }); } - public async auto(message: GuildMessage, args: SkyraSubcommand.Args) { - const role = await args.pick('roleName').catch(() => message.guild.roles.everyone); - const channel = args.finished ? assertNonThread(message.channel) : await args.pick('textChannelName'); - if (this.getLock(role, channel)) return this.handleUnlock(message, args, role, channel); + public override async chatInputRun(interaction: SkyraCommand.Interaction) { + const durationRaw = interaction.options.getString('duration'); + const durationResult = this.#parseDuration(durationRaw); + const t = getSupportedUserLanguageT(interaction); + if (durationResult?.isErr()) { + const content = t(durationResult.unwrapErr(), { parameter: durationRaw! }); + return interaction.reply({ content, flags: MessageFlags.Ephemeral }); + } + + const duration = durationResult.unwrap(); + const global = interaction.options.getBoolean('global') ?? false; + const channel = + interaction.options.getChannel('channel') ?? (global ? null : (interaction.channel as SupportedChannel)); + const role = interaction.options.getRole('role') ?? interaction.guild!.roles.everyone; + const action = interaction.options.getString('action', true)! as 'lock' | 'unlock'; - const duration = args.finished ? null : await args.pick('timespan', { minimum: 0 }); - return this.handleLock(message, args, role, channel, duration); + const content = + action === 'lock' + ? await this.#lock(t, interaction.user, channel, role, duration) + : await this.#unlock(t, interaction.user, channel, role); + return interaction.reply({ content, flags: MessageFlags.Ephemeral }); } - public async unlock(message: GuildMessage, args: SkyraSubcommand.Args) { - const role = await args.pick('roleName').catch(() => message.guild.roles.everyone); - const channel = args.finished ? assertNonThread(message.channel) : await args.pick('textChannelName'); - return this.handleUnlock(message, args, role, channel); + public override registerApplicationCommands(registry: ApplicationCommandRegistry) { + registry.registerChatInputCommand((builder) => + applyLocalizedBuilder(builder, Root.Name, Root.Description) // + .addStringOption((option) => + applyLocalizedBuilder(option, Root.Action) + .setRequired(true) + .addChoices( + createLocalizedChoice(Root.ActionLock, { value: 'lock' }), + createLocalizedChoice(Root.ActionUnlock, { value: 'unlock' }) + ) + ) + .addRoleOption((option) => applyLocalizedBuilder(option, Root.Role)) + .addChannelOption((option) => applyLocalizedBuilder(option, Root.Channel)) + .addStringOption((option) => applyLocalizedBuilder(option, Root.Duration)) + .setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels | PermissionFlagsBits.ManageRoles) + .setDMPermission(false) + ); } - public async lock(message: GuildMessage, args: SkyraSubcommand.Args) { - const role = await args.pick('roleName').catch(() => message.guild.roles.everyone); - const channel = args.finished ? assertNonThread(message.channel) : await args.pick('textChannelName'); - const duration = args.finished ? null : await args.pick('timespan', { minimum: 0 }); - return this.handleLock(message, args, role, channel, duration); + #lock(t: TFunction, user: User, channel: SupportedChannel | null, role: Role, duration: number | null): Promise { + return isNullish(channel) + ? this.#lockGuild(t, user, role, duration) + : channel.isThread() + ? this.#lockThread(t, user, channel, duration) + : this.#lockChannel(t, user, channel, role, duration); } - private async handleLock( - message: GuildMessage, - args: SkyraSubcommand.Args, - role: Role, - channel: NonThreadGuildTextBasedChannelTypes, - duration: number | null - ) { - // If there was a lockdown, abort lock - const lock = this.getLock(role, channel); - if (lock !== null) { - this.error(LanguageKeys.Commands.Moderation.LockdownLocked, { channel: channel.toString() }); + async #lockGuild(t: TFunction, user: User, role: Role, duration: number | null) { + if (!role.permissions.has(LockdownPermissions)) { + return t(Root.GuildLocked, { role: role.toString() }); } - const allowed = this.isAllowed(role, channel); + const reason = t(Root.AuditLogLockRequestedBy, { user: getTag(user) }); + const permissionsApplied = LockdownPermissions; + const permissionsOriginal = PermissionsBits.intersection(role.permissions.bitfield, LockdownPermissions); + const result = await toErrorCodeResult( + role.setPermissions(PermissionsBits.difference(role.permissions.bitfield, LockdownPermissions), reason) + ); + return result.match({ + ok: () => this.#lockGuildOk(t, user, role, permissionsApplied, permissionsOriginal, duration), + err: (code) => this.#lockGuildErr(t, role, code) + }); + } - // If they can send, begin locking - const response = await send(message, args.t(LanguageKeys.Commands.Moderation.LockdownLocking, { channel: channel.toString() })); - await channel.permissionOverwrites.edit(role, { SendMessages: false }); - if (canSendMessages(message.channel)) { - await response.edit(args.t(LanguageKeys.Commands.Moderation.LockdownLock, { channel: channel.toString() })).catch(() => null); + async #lockGuildOk(t: TFunction, user: User, role: Role, permissionsApplied: bigint, permissionsOriginal: bigint, duration: number | null) { + if (!isNullishOrZero(duration)) { + await this.#schedule( + { + type: LockdownType.Guild, + guildId: role.guild.id, + userId: user.id, + roleId: role.id, + permissionsApplied: Number(permissionsApplied), + permissionsOriginal: Number(permissionsOriginal) + }, + duration + ); } + return t(Root.SuccessGuild, { role: role.toString() }); + } + + #lockGuildErr(t: TFunction, role: Role, code: RESTJSONErrorCodes) { + if (code === RESTJSONErrorCodes.UnknownRole) return t(Root.GuildUnknownRole, { role: role.toString() }); - // Create the timeout - const timeout = duration - ? setAccurateTimeout(() => floatPromise(this.performUnlock(message, args.t, role, channel, allowed)), duration) - : null; - getSecurity(message.guild).lockdowns.add(role, channel, { allowed, timeout }); + this.container.logger.error(`${getLogPrefix(this)} ${getCodeStyle(code)} Could not lock the guild ${role.id}`); + return t(Root.GuildLockFailed, { role: role.toString() }); } - private isAllowed(role: Role, channel: NonThreadGuildTextBasedChannelTypes): boolean | null { - return channel.permissionOverwrites.cache.get(role.id)?.allow.has(PermissionFlagsBits.SendMessages, false) ?? null; + async #lockThread(t: TFunction, user: User, channel: SupportedThreadChannel, duration: number | null) { + if (channel.locked) { + return t(Root.ThreadLocked, { channel: channelMention(channel.id) }); + } + + if (!channel.manageable) { + return t(Root.ThreadUnmanageable, { channel: channelMention(channel.id) }); + } + + const reason = t(Root.AuditLogLockRequestedBy, { user: getTag(user) }); + const result = await toErrorCodeResult(channel.setLocked(true, reason)); + return result.match({ + ok: () => this.#lockThreadOk(t, user, channel, duration), + err: (code) => this.#lockThreadErr(t, channel, code) + }); } - private async handleUnlock(message: GuildMessage, args: SkyraSubcommand.Args, role: Role, channel: NonThreadGuildTextBasedChannelTypes) { - const entry = this.getLock(role, channel); - if (entry === null) this.error(LanguageKeys.Commands.Moderation.LockdownUnlocked, { channel: channel.toString() }); - if (entry.timeout) clearAccurateTimeout(entry.timeout); - return this.performUnlock(message, args.t, role, channel, entry.allowed); + async #lockThreadOk(t: TFunction, user: User, channel: SupportedThreadChannel, duration: number | null) { + if (!isNullishOrZero(duration)) { + await this.#schedule( + { + type: LockdownType.Thread, + guildId: channel.guild.id, + userId: user.id, + channelId: channel.id + }, + duration + ); + } + return t(Root.SuccessThread, { channel: channelMention(channel.id) }); + } + + #lockThreadErr(t: TFunction, channel: SupportedThreadChannel, code: RESTJSONErrorCodes) { + if (code === RESTJSONErrorCodes.UnknownChannel) return t(Root.ThreadUnknownChannel, { channel: channelMention(channel.id) }); + + this.container.logger.error(`${getLogPrefix(this)} ${getCodeStyle(code)} Could not lock the thread ${channel.id}`); + return t(Root.ThreadLockFailed, { channel: channelMention(channel.id) }); } - private async performUnlock( - message: GuildMessage, + async #lockChannel(t: TFunction, user: User, channel: SupportedNonThreadChannel, role: Role, duration: number | null) { + const permissions = this.#getPermissionsForLockdownChannel(channel, role); + if (!channel.permissionsFor(role).has(permissions.applied)) { + return t(Root.ChannelLocked, { channel: channelMention(channel.id) }); + } + + if (!channel.manageable) { + return t(Root.ChannelUnmanageable, { channel: channelMention(channel.id) }); + } + + const reason = t(Root.AuditLogLockRequestedBy, { user: getTag(user) }); + const result = await toErrorCodeResult( + channel.permissionOverwrites.edit(role, this.#lockChannelGetPermissionsArray(permissions.applied), { reason }) + ); + return result.match({ + ok: () => this.#lockChannelOk(t, user, channel, role, permissions.applied, permissions.originalAllow, permissions.originalDeny, duration), + err: (code) => this.#lockChannelErr(t, channel, code) + }); + } + + #getPermissionsForLockdownChannel(channel: SupportedNonThreadChannel, role: Role) { + const existing = channel.permissionOverwrites.cache.get(role.id) ?? null; + + const isText = channel.isTextBased(); + const isVoice = channel.isVoiceBased(); + + let applied: bigint; + if (channel.type === ChannelType.GuildCategory || (isText && isVoice)) { + applied = LockdownMixedPermissions; + } else if (isText) { + applied = LockdownTextPermissions; + } else { + applied = LockdownVoicePermissions; + } + + return { + applied, + originalAllow: PermissionsBits.intersection(existing?.allow.bitfield ?? 0n, applied), + originalDeny: PermissionsBits.intersection(existing?.deny.bitfield ?? 0n, applied) + }; + } + + #lockChannelGetPermissionsArray(bitfield: bigint) { + return Object.fromEntries(PermissionsBits.toArray(bitfield).map((name) => [name, false])); + } + + async #lockChannelOk( t: TFunction, + user: User, + channel: SupportedNonThreadChannel, role: Role, - channel: NonThreadGuildTextBasedChannelTypes, - allowed: boolean | null + permissionsApplied: bigint, + permissionsOriginalAllow: bigint, + permissionsOriginalDeny: bigint, + duration: number | null ) { - getSecurity(channel.guild).lockdowns.remove(role, channel); + if (!isNullishOrZero(duration)) { + await this.#schedule( + { + type: LockdownType.Channel, + guildId: channel.guild.id, + userId: user.id, + channelId: channel.id, + roleId: role.id, + permissionsApplied: Number(permissionsApplied), + permissionsOriginalAllow: Number(permissionsOriginalAllow), + permissionsOriginalDeny: Number(permissionsOriginalDeny) + }, + duration + ); + } + return t(Root.SuccessChannel, { channel: channelMention(channel.id) }); + } + + #lockChannelErr(t: TFunction, channel: SupportedNonThreadChannel, code: RESTJSONErrorCodes) { + if (code === RESTJSONErrorCodes.UnknownChannel) return t(Root.ChannelUnknownChannel, { channel: channelMention(channel.id) }); - const overwrites = channel.permissionOverwrites.cache.get(role.id); - if (overwrites === undefined) return; + this.container.logger.error(`${getLogPrefix(this)} ${getCodeStyle(code)} Could not lock the channel ${channel.id}`); + return t(Root.ChannelLockFailed, { channel: channelMention(channel.id) }); + } + + #unlock(t: TFunction, user: User, channel: SupportedChannel | null, role: Role): Promise { + // TODO: Implement lockdown task requesting for unlock recovery data + return isNullish(channel) + ? this.#unlockGuild(t, user, role) + : channel.isThread() + ? this.#unlockThread(t, user, channel) + : this.#unlockChannel(t, user, channel, role); + } - // If the only permission overwrite is the denied SEND_MESSAGES, clean up the entire permission; if the permission - // was denied, reset it to the default state, otherwise don't run an extra query - if (overwrites.allow.bitfield === 0n && overwrites.deny.bitfield === PermissionFlagsBits.SendMessages) { - await overwrites.delete(); - } else if (overwrites.deny.has(PermissionFlagsBits.SendMessages)) { - await overwrites.edit({ SendMessages: allowed }); + async #unlockChannel(t: TFunction, user: User, channel: SupportedNonThreadChannel, role: Role) { + if (channel.permissionsFor(role).has(LockdownPermissions)) { + return t(Root.ChannelUnlocked, { channel: channelMention(channel.id) }); } - if (canSendMessages(message.channel)) { - const content = t(LanguageKeys.Commands.Moderation.LockdownOpen, { channel: channel.toString() }); - await send(message, content); + if (!channel.manageable) { + return t(Root.ChannelUnmanageable, { channel: channelMention(channel.id) }); } + + const reason = t(Root.AuditLogUnlockRequestedBy, { user: getTag(user) }); + // TODO: Implement permission locking based on channel type + const result = await toErrorCodeResult( + channel.permissionOverwrites.edit(role, { SendMessages: true, SendMessagesInThreads: true }, { reason }) + ); + return result.match({ + ok: () => this.#unlockChannelOk(t, channel), + err: (code) => this.#unlockChannelErr(t, channel, code) + }); + } + + #unlockChannelOk(t: TFunction, channel: SupportedNonThreadChannel) { + return t(Root.SuccessChannel, { channel: channelMention(channel.id) }); } - private getLock(role: Role, channel: NonThreadGuildTextBasedChannelTypes): LockdownManager.Entry | null { - const entry = getSecurity(channel.guild).lockdowns.get(channel.id)?.get(role.id); - if (entry) return entry; + #unlockChannelErr(t: TFunction, channel: SupportedNonThreadChannel, code: RESTJSONErrorCodes) { + if (code === RESTJSONErrorCodes.UnknownChannel) return t(Root.ChannelUnknownChannel, { channel: channelMention(channel.id) }); - const permissions = channel.permissionOverwrites.cache.get(role.id)?.deny.has(PermissionFlagsBits.SendMessages); - return permissions === true ? { allowed: null, timeout: null } : null; + this.container.logger.error(`${getLogPrefix(this)} ${getCodeStyle(code)} Could not unlock the channel ${channel.id}`); + return t(Root.ChannelLockFailed, { channel: channelMention(channel.id) }); + } + + async #unlockThread(t: TFunction, user: User, channel: SupportedThreadChannel) { + if (!channel.locked) { + return t(Root.ThreadUnlocked, { channel: channelMention(channel.id) }); + } + + if (!channel.manageable) { + return t(Root.ThreadUnmanageable, { channel: channelMention(channel.id) }); + } + + const reason = t(Root.AuditLogUnlockRequestedBy, { user: getTag(user) }); + const result = await toErrorCodeResult(channel.setLocked(false, reason)); + return result.match({ + ok: () => this.#unlockThreadOk(t, channel), + err: (code) => this.#unlockThreadErr(t, channel, code) + }); + } + + #unlockThreadOk(t: TFunction, channel: SupportedThreadChannel) { + return t(Root.SuccessThread, { channel: channelMention(channel.id) }); + } + + #unlockThreadErr(t: TFunction, channel: SupportedThreadChannel, code: RESTJSONErrorCodes) { + if (code === RESTJSONErrorCodes.UnknownChannel) return t(Root.ThreadUnknownChannel, { channel: channelMention(channel.id) }); + + this.container.logger.error(`${getLogPrefix(this)} ${getCodeStyle(code)} Could not unlock the thread ${channel.id}`); + return t(Root.ThreadUnlockFailed, { channel: channelMention(channel.id) }); + } + + async #unlockGuild(t: TFunction, user: User, role: Role) { + if (role.permissions.has(LockdownPermissions)) { + return t(Root.GuildUnlocked, { role: role.toString() }); + } + + const reason = t(Root.AuditLogUnlockRequestedBy, { user: getTag(user) }); + const result = await toErrorCodeResult(role.setPermissions(PermissionsBits.union(role.permissions.bitfield, LockdownPermissions), reason)); + return result.match({ + ok: () => this.#unlockGuildOk(t, role), + err: (error) => this.#unlockGuildErr(t, role, error) + }); + } + + #unlockGuildOk(t: TFunction, role: Role) { + return t(Root.SuccessGuild, { role: role.toString() }); + } + + #unlockGuildErr(t: TFunction, role: Role, code: RESTJSONErrorCodes) { + if (code === RESTJSONErrorCodes.UnknownRole) return t(Root.GuildUnknownRole, { role: role.toString() }); + + this.container.logger.error(`${getLogPrefix(this)} ${getCodeStyle(code)} Could not unlock the guild ${role.id}`); + return t(Root.GuildUnlockFailed, { role: role.toString() }); + } + + #schedule(data: LockdownData, duration: number) { + return this.container.schedule.add('moderationEndLockdown', duration, { catchUp: true, data }); + } + + #parseDuration(value: string | null) { + if (isNullish(value)) return ok(null); + return resolveTimeSpan(value, { minimum: Time.Second * 30, maximum: months(1) }); } } + +type SupportedChannel = CategoryChannel | ForumChannel | MediaChannel | GuildTextBasedChannel | VoiceBasedChannel | AnyThreadChannel; +type SupportedChannelType = Exclude; +type SupportedThreadChannel = AnyThreadChannel; +type SupportedNonThreadChannel = Exclude; diff --git a/src/commands/System/Admin/eval.ts b/src/commands/System/Admin/eval.ts index cc9a1cf014e..5ff20d53584 100644 --- a/src/commands/System/Admin/eval.ts +++ b/src/commands/System/Admin/eval.ts @@ -116,8 +116,7 @@ export class UserCommand extends SkyraCommand { }, structures: { ...(await import('#lib/structures')), - data: await import('#lib/structures/data'), - managers: await import('#lib/structures/managers') + data: await import('#lib/structures/data') } }, container: this.container, diff --git a/src/languages/en-US/commands/lockdown.json b/src/languages/en-US/commands/lockdown.json new file mode 100644 index 00000000000..0eec7b4aff7 --- /dev/null +++ b/src/languages/en-US/commands/lockdown.json @@ -0,0 +1,37 @@ +{ + "name": "lockdown", + "description": "Manage the server's lockdown status", + "actionName": "action", + "actionDescription": "The action to perform", + "channelName": "channel", + "channelDescription": "The channel to lock down", + "durationName": "duration", + "durationDescription": "How long the lockdown should last", + "roleName": "role", + "roleDescription": "The role to use for the lockdown", + "globalName": "global", + "globalDescription": "⚠️ Whether or not to apply the lockdown to the entire server", + "actionLock": "Lock", + "actionUnlock": "Unlock", + "auditLogLockRequestedBy": "Channel locked at request of {{user}}", + "auditLogUnlockRequestedBy": "Channel unlocked at request of {{user}}", + "guildLocked": "{{role}} is already locked down in the server.", + "guildUnlocked": "{{role}} is currently not locked down in the server.", + "successGuild": "Successfully updated the lockdown status for {{role}} in the server.", + "guildUnknownRole": "I somehow could not find the role {{role}}, you can try with other roles.", + "guildLockFailed": "The role {{role}} could not be locked down, please try again later.", + "guildUnlockFailed": "The role {{role}} could not be unlocked, please try again later.", + "successThread": "Successfully updated the lockdown status for the thread {{channel}} in the server.", + "threadLocked": "The thread {{channel}} is already locked.", + "threadUnlocked": "The thread {{channel}} is currently not locked.", + "threadUnmanageable": "I cannot manage the thread {{channel}}, please update my permissions and try again.", + "threadUnknownChannel": "I somehow could not find the thread {{channel}}, you can try with other channels.", + "threadLockFailed": "The thread {{channel}} could not be locked, please try again later.", + "threadUnlockFailed": "The thread {{channel}} could not be unlocked, please try again later.", + "successChannel": "Successfully updated the lockdown status for the channel {{channel}} in the server", + "channelLocked": "The channel {{channel}} is already locked.", + "channelUnlocked": "The channel {{channel}} is currently not locked.", + "channelUnmanageable": "I cannot manage the channel {{channel}}, please update my permissions and try again.", + "channelUnknownChannel": "I somehow could not find the channel {{channel}}, you can try with other channels.", + "channelLockFailed": "The channel {{channel}} could not be locked, please try again later." +} diff --git a/src/lib/i18n/languageKeys/keys/Commands.ts b/src/lib/i18n/languageKeys/keys/Commands.ts index 79dd3efaceb..41565fc2228 100644 --- a/src/lib/i18n/languageKeys/keys/Commands.ts +++ b/src/lib/i18n/languageKeys/keys/Commands.ts @@ -5,6 +5,7 @@ export * as Fun from '#lib/i18n/languageKeys/keys/commands/Fun'; export * as Games from '#lib/i18n/languageKeys/keys/commands/Games'; export * as General from '#lib/i18n/languageKeys/keys/commands/General'; export * as Info from '#lib/i18n/languageKeys/keys/commands/Info'; +export * as Lockdown from '#lib/i18n/languageKeys/keys/commands/Lockdown'; export * as Management from '#lib/i18n/languageKeys/keys/commands/Management'; export * as Misc from '#lib/i18n/languageKeys/keys/commands/Misc'; export * as Moderation from '#lib/i18n/languageKeys/keys/commands/Moderation'; diff --git a/src/lib/i18n/languageKeys/keys/commands/Lockdown.ts b/src/lib/i18n/languageKeys/keys/commands/Lockdown.ts new file mode 100644 index 00000000000..0160f86cb27 --- /dev/null +++ b/src/lib/i18n/languageKeys/keys/commands/Lockdown.ts @@ -0,0 +1,45 @@ +import { FT, T } from '#lib/types'; +import type { ChannelMention, RoleMention } from 'discord.js'; + +// Root +export const Name = T('commands/lockdown:name'); +export const Description = T('commands/lockdown:description'); + +// Options +export const Action = 'commands/lockdown:action'; +export const Channel = 'commands/lockdown:channel'; +export const Duration = 'commands/lockdown:duration'; +export const Role = 'commands/lockdown:role'; +export const Global = 'commands/lockdown:global'; + +// Action choices +export const ActionLock = T('commands/lockdown:actionLock'); +export const ActionUnlock = T('commands/lockdown:actionUnlock'); + +export const AuditLogLockRequestedBy = FT<{ user: string }>('commands/lockdown:auditLogLockRequestedBy'); +export const AuditLogUnlockRequestedBy = FT<{ user: string }>('commands/lockdown:auditLogUnlockRequestedBy'); + +// Guild +export const GuildLocked = FT<{ role: RoleMention }>('commands/lockdown:guildLocked'); +export const GuildUnlocked = FT<{ role: RoleMention }>('commands/lockdown:guildUnlocked'); +export const SuccessGuild = FT<{ role: RoleMention }>('commands/lockdown:successGuild'); +export const GuildUnknownRole = FT<{ role: RoleMention }>('commands/lockdown:guildUnknownRole'); +export const GuildLockFailed = FT<{ role: RoleMention }>('commands/lockdown:guildLockFailed'); +export const GuildUnlockFailed = FT<{ role: RoleMention }>('commands/lockdown:guildUnlockFailed'); + +// Thread +export const SuccessThread = FT<{ channel: ChannelMention }>('commands/lockdown:successThread'); +export const ThreadLocked = FT<{ channel: ChannelMention }>('commands/lockdown:threadLocked'); +export const ThreadUnlocked = FT<{ channel: ChannelMention }>('commands/lockdown:threadUnlocked'); +export const ThreadUnmanageable = FT<{ channel: ChannelMention }>('commands/lockdown:threadUnmanageable'); +export const ThreadUnknownChannel = FT<{ channel: ChannelMention }>('commands/lockdown:threadUnknownChannel'); +export const ThreadLockFailed = FT<{ channel: ChannelMention }>('commands/lockdown:threadLockFailed'); +export const ThreadUnlockFailed = FT<{ channel: ChannelMention }>('commands/lockdown:threadUnlockFailed'); + +// Channel +export const SuccessChannel = FT<{ channel: ChannelMention }>('commands/lockdown:successChannel'); +export const ChannelLocked = FT<{ channel: ChannelMention }>('commands/lockdown:channelLocked'); +export const ChannelUnlocked = FT<{ channel: ChannelMention }>('commands/lockdown:channelUnlocked'); +export const ChannelUnmanageable = FT<{ channel: ChannelMention }>('commands/lockdown:channelUnmanageable'); +export const ChannelUnknownChannel = FT<{ channel: ChannelMention }>('commands/lockdown:channelUnknownChannel'); +export const ChannelLockFailed = FT<{ channel: ChannelMention }>('commands/lockdown:channelLockFailed'); diff --git a/src/lib/moderation/managers/ModerationManagerEntry.ts b/src/lib/moderation/managers/ModerationManagerEntry.ts index 9d70f999732..af2b7fdf07f 100644 --- a/src/lib/moderation/managers/ModerationManagerEntry.ts +++ b/src/lib/moderation/managers/ModerationManagerEntry.ts @@ -274,7 +274,12 @@ export class ModerationManagerEntry } #isMatchingTask(task: ScheduleEntry) { - return task.data !== null && task.data.caseID === this.id && task.data.guildID === this.guild.id; + return ( + task.data !== null && // + 'caseID' in task.data && + task.data.caseID === this.id && + task.data.guildID === this.guild.id + ); } #setDuration(duration: bigint | number | null) { diff --git a/src/lib/schedule/manager/ScheduleEntry.ts b/src/lib/schedule/manager/ScheduleEntry.ts index 94a253121b9..8e5673c084e 100644 --- a/src/lib/schedule/manager/ScheduleEntry.ts +++ b/src/lib/schedule/manager/ScheduleEntry.ts @@ -5,6 +5,7 @@ import type { Schedule } from '@prisma/client'; import { container } from '@sapphire/framework'; import { Cron } from '@sapphire/time-utilities'; import { isNullishOrEmpty } from '@sapphire/utilities'; +import type { Snowflake } from 'discord-api-types/v10'; export class ScheduleEntry { public id: number; @@ -126,6 +127,7 @@ export namespace ScheduleEntry { export interface TaskData { poststats: null; syncResourceAnalytics: null; + moderationEndLockdown: LockdownData; moderationEndAddRole: SharedModerationTaskData; moderationEndBan: SharedModerationTaskData; moderationEndMute: SharedModerationTaskData; @@ -166,3 +168,79 @@ export namespace ScheduleEntry { data?: TaskData[Type]; } } + +export type LockdownData = LockdownGuildData | LockdownChannelData | LockdownThreadData; + +export enum LockdownType { + Guild, + Channel, + Thread +} + +export interface BaseLockdownData { + /** + * The type of lockdown that was applied. + */ + type: T; + + /** + * The ID of the guild where the lockdown was applied. + */ + guildId: Snowflake; + + /** + * The ID of the user who initiated the lockdown. + */ + userId: Snowflake; +} + +export interface LockdownGuildData extends BaseLockdownData { + /** + * The ID of the role that was locked down. + */ + roleId: Snowflake; + + /** + * The permissions that were applied to the role, as a bitfield. + */ + permissionsApplied: number; + + /** + * The original permissions for the role before the lockdown. + */ + permissionsOriginal: number; +} + +export interface LockdownChannelData extends BaseLockdownData { + /** + * The ID of the channel where the lockdown was applied. + */ + channelId: Snowflake; + + /** + * The ID of the role that was locked down in the channel. + */ + roleId: Snowflake; + + /** + * The permissions that were applied to the role, as a bitfield. + */ + permissionsApplied: number | null; + + /** + * The original allow overrides for the role before the lockdown. + */ + permissionsOriginalAllow: number; + + /** + * The original deny overrides for the role before the lockdown. + */ + permissionsOriginalDeny: number; +} + +export interface LockdownThreadData extends BaseLockdownData { + /** + * The ID of the thread where the lockdown was applied. + */ + channelId: Snowflake; +} diff --git a/src/lib/schedule/structures/Task.ts b/src/lib/schedule/structures/Task.ts index 93344e1bba6..33f5aa71e9d 100644 --- a/src/lib/schedule/structures/Task.ts +++ b/src/lib/schedule/structures/Task.ts @@ -1,4 +1,4 @@ -import type { PartialResponseValue } from '#lib/schedule/manager/ScheduleEntry'; +import { ResponseType, type PartialResponseValue } from '#lib/schedule/manager/ScheduleEntry'; import { Piece } from '@sapphire/framework'; import type { Awaitable } from '@sapphire/utilities'; @@ -8,6 +8,22 @@ export abstract class Task extends Piece { * @param data The data */ public abstract run(data: unknown): Awaitable; + + protected ignore() { + return { type: ResponseType.Ignore } as const satisfies PartialResponseValue; + } + + protected finish() { + return { type: ResponseType.Finished } as const satisfies PartialResponseValue; + } + + protected delay(value: number) { + return { type: ResponseType.Delay, value } as const satisfies PartialResponseValue; + } + + protected update(value: Date) { + return { type: ResponseType.Update, value } as const satisfies PartialResponseValue; + } } export namespace Task { diff --git a/src/lib/structures/index.ts b/src/lib/structures/index.ts index 37c7a22d48b..03e4c76a9b4 100644 --- a/src/lib/structures/index.ts +++ b/src/lib/structures/index.ts @@ -3,5 +3,4 @@ export * from '#lib/structures/InviteStore'; export * from '#lib/structures/SettingsMenu'; export * from '#lib/structures/commands/index'; export * from '#lib/structures/listeners/index'; -export * from '#lib/structures/managers'; export * from '#lib/structures/preconditions/index'; diff --git a/src/lib/structures/managers/LockdownManager.ts b/src/lib/structures/managers/LockdownManager.ts deleted file mode 100644 index f1f8293f545..00000000000 --- a/src/lib/structures/managers/LockdownManager.ts +++ /dev/null @@ -1,73 +0,0 @@ -import type { AccurateTimeout } from '#utils/Timers'; -import type { GuildTextBasedChannelTypes } from '@sapphire/discord.js-utilities'; -import { Collection, type Role } from 'discord.js'; - -export class LockdownManager extends Collection> { - public add(role: Role, channel: LockdownManager.Channel, value: LockdownManager.Entry) { - const roles = this.acquire(channel); - roles.get(role.id)?.timeout?.stop(); - roles.set(role.id, value); - } - - public remove(role: Role): this; - public remove(role: Role, channel: LockdownManager.Channel): boolean; - public remove(role: Role, channel?: LockdownManager.Channel) { - if (channel === undefined) return this.removeRole(role); - - const channels = this.get(channel.id); - if (channels === undefined) return false; - - const entry = channels.get(role.id); - if (entry === undefined) return false; - - entry.timeout?.stop(); - channels.delete(role.id); - - if (channels.size === 0) { - super.delete(channel.id); - } - - return true; - } - - public override delete(id: string) { - const roles = this.get(id); - if (roles === undefined) return false; - - for (const role of roles.values()) { - role.timeout?.stop(); - } - - return super.delete(id); - } - - public acquire(channel: LockdownManager.Channel) { - let collection = this.get(channel.id); - if (collection === undefined) { - collection = new Collection(); - this.set(channel.id, collection); - } - - return collection; - } - - private removeRole(role: Role) { - for (const channel of this.values()) { - const entry = channel.get(role.id); - if (entry === undefined) continue; - - entry.timeout?.stop(); - channel.delete(role.id); - } - - return this; - } -} - -export namespace LockdownManager { - export type Channel = GuildTextBasedChannelTypes; - export interface Entry { - allowed: boolean | null; - timeout: AccurateTimeout | null; - } -} diff --git a/src/lib/structures/managers/index.ts b/src/lib/structures/managers/index.ts deleted file mode 100644 index 3855d5a1f06..00000000000 --- a/src/lib/structures/managers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from '#lib/structures/managers/LockdownManager'; diff --git a/src/lib/util/Security/GuildSecurity.ts b/src/lib/util/Security/GuildSecurity.ts deleted file mode 100644 index 28a873a3f4d..00000000000 --- a/src/lib/util/Security/GuildSecurity.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { LockdownManager } from '#lib/structures'; -import type { Guild } from 'discord.js'; - -/** - * @version 3.0.0 - */ -export class GuildSecurity { - /** - * The {@link Guild} instance which manages this instance - */ - public guild: Guild; - - /** - * The lockdowns map - */ - public lockdowns = new LockdownManager(); - - public constructor(guild: Guild) { - this.guild = guild; - } -} diff --git a/src/lib/util/Timers.ts b/src/lib/util/Timers.ts deleted file mode 100644 index e1eb2142b33..00000000000 --- a/src/lib/util/Timers.ts +++ /dev/null @@ -1,34 +0,0 @@ -export function setAccurateTimeout(fn: (...args: T) => void, delay: number, ...args: T) { - const end = Date.now() + delay; - const context: AccurateTimeout = { - timeout: null!, - fn, - cb(...args: T) { - const remaining = end - Date.now(); - if (remaining < 1) { - fn(...args); - } else { - // eslint-disable-next-line @typescript-eslint/unbound-method - context.timeout = setTimeout(context.cb, delay, ...args).unref(); - } - }, - stop() { - clearAccurateTimeout(context); - } - }; - - // eslint-disable-next-line @typescript-eslint/unbound-method - context.timeout = setTimeout(context.cb, delay, ...args).unref(); - return context; -} - -export function clearAccurateTimeout(context: AccurateTimeout) { - clearTimeout(context.timeout); -} - -export interface AccurateTimeout { - timeout: NodeJS.Timeout; - fn(...args: T): void; - cb(...args: T): void; - stop(): void; -} diff --git a/src/lib/util/functions/guild.ts b/src/lib/util/functions/guild.ts index ef65ca2a3ee..7273604f6dc 100644 --- a/src/lib/util/functions/guild.ts +++ b/src/lib/util/functions/guild.ts @@ -1,12 +1,10 @@ import { LoggerManager, ModerationManager, StickyRoleManager } from '#lib/moderation/managers'; import { resolveGuild } from '#utils/common'; -import { GuildSecurity } from '#utils/Security/GuildSecurity'; import type { Guild, GuildResolvable } from 'discord.js'; interface GuildUtilities { readonly logger: LoggerManager; readonly moderation: ModerationManager; - readonly security: GuildSecurity; readonly stickyRoles: StickyRoleManager; } @@ -20,7 +18,6 @@ export function getGuildUtilities(resolvable: GuildResolvable): GuildUtilities { const entry: GuildUtilities = { logger: new LoggerManager(guild), moderation: new ModerationManager(guild), - security: new GuildSecurity(guild), stickyRoles: new StickyRoleManager(guild) }; cache.set(guild, entry); @@ -30,7 +27,6 @@ export function getGuildUtilities(resolvable: GuildResolvable): GuildUtilities { export const getLogger = getProperty('logger'); export const getModeration = getProperty('moderation'); -export const getSecurity = getProperty('security'); export const getStickyRoles = getProperty('stickyRoles'); function getProperty(property: K) { diff --git a/src/tasks/moderation/moderationEndLockdown.ts b/src/tasks/moderation/moderationEndLockdown.ts new file mode 100644 index 00000000000..cd796ba2dab --- /dev/null +++ b/src/tasks/moderation/moderationEndLockdown.ts @@ -0,0 +1,138 @@ +import { LockdownType, Task, type LockdownData } from '#lib/schedule'; +import { getLogPrefix } from '#utils/functions'; +import { Result } from '@sapphire/framework'; +import { isNullish } from '@sapphire/utilities'; +import { + DiscordAPIError, + HTTPError, + PermissionFlagsBits, + RESTJSONErrorCodes, + type AnyThreadChannel, + type CategoryChannel, + type ForumChannel, + type Guild, + type MediaChannel, + type NewsChannel, + type PrivateThreadChannel, + type PublicThreadChannel, + type Snowflake, + type StageChannel, + type TextChannel, + type VoiceChannel +} from 'discord.js'; + +const LockdownPermissions = PermissionFlagsBits.SendMessages | PermissionFlagsBits.SendMessagesInThreads; +const IgnoredChannelErrors = [RESTJSONErrorCodes.UnknownGuild, RESTJSONErrorCodes.UnknownRole, RESTJSONErrorCodes.UnknownChannel]; +const IgnoredGuildErrors = [RESTJSONErrorCodes.UnknownGuild, RESTJSONErrorCodes.UnknownRole]; + +export class UserTask extends Task { + public override run(data: LockdownData) { + const guild = this.container.client.guilds.cache.get(data.guildId); + if (isNullish(guild)) return this.finish(); + + return data.type === LockdownType.Guild // + ? this.#unlockGuild(guild, data.roleId) + : data.type === LockdownType.Channel + ? this.#unlockChannel(guild, data.roleId, data.channelId) + : this.#unlockChannel(guild, null, data.channelId); + } + + async #unlockChannel(guild: Guild, roleId: Snowflake | null, channelId: Snowflake) { + const channel = guild.channels.cache.get(channelId); + if (isNullish(channel)) return this.finish(); + + const me = await guild.members.fetchMe(); + // If the bot cannot manage channels, ignore: + if (!me.permissions.has(PermissionFlagsBits.ManageChannels | PermissionFlagsBits.ManageRoles)) return this.finish(); + + return channel.isThread() // + ? this.#unlockChannelThread(channel) + : this.#unlockChannelNonThread(channel, roleId!); + } + + async #unlockChannelNonThread(channel: SupportedNonThreadChannel, roleId: Snowflake) { + const role = channel.guild.roles.cache.get(roleId); + if (isNullish(role)) return this.finish(); + + // If the permissions have been restored, ignore: + if (channel.permissionsFor(role).has(LockdownPermissions)) return this.finish(); + + // If the bot cannot manage the channel, ignore: + if (!channel.manageable) return this.finish(); + + const result = await Result.fromAsync(channel.permissionOverwrites.edit(role, { SendMessages: false, SendMessagesInThreads: false })); + return result.match({ + ok: () => this.finish(), + err: (error) => this.#unlockChannelError(error as Error) + }); + } + + async #unlockChannelThread(channel: SupportedThreadChannel) { + // If the thread is not locked, ignore: + if (!channel.locked) return this.finish(); + + // If the bot cannot manage the thread, ignore: + if (!channel.manageable) return this.finish(); + + const result = await Result.fromAsync(channel.setLocked(true)); + return result.match({ + ok: () => this.finish(), + err: (error) => this.#unlockChannelError(error as Error) + }); + } + + #unlockChannelError(error: Error) { + // If the error is an AbortError, delay for 5 seconds: + if (error.name === 'AbortError') return this.delay(5000); + // If the error is an HTTPError, delay for 30 seconds: + if (error instanceof HTTPError) return this.delay(30000); + // If the error is an UnknownChannel error, ignore: + if (error instanceof DiscordAPIError && IgnoredChannelErrors.includes(error.code as number)) return this.finish(); + // Otherwise, emit the error: + this.container.logger.error(getLogPrefix(this), error); + return this.finish(); + } + + async #unlockGuild(guild: Guild, roleId: Snowflake) { + const role = guild.roles.cache.get(roleId); + if (isNullish(role)) return this.finish(); + + // If the permissions have been restored, ignore: + if (role.permissions.has(LockdownPermissions)) return this.finish(); + + const me = await guild.members.fetchMe(); + // If the bot cannot manage roles, ignore: + if (!me.permissions.has(PermissionFlagsBits.ManageRoles)) return this.finish(); + + const result = await Result.fromAsync(role.setPermissions(role.permissions.bitfield | LockdownPermissions)); + return result.match({ + ok: () => this.finish(), + err: (error) => this.#unlockGuildError(error as Error) + }); + } + + #unlockGuildError(error: Error) { + // If the error is an AbortError, delay for 5 seconds: + if (error.name === 'AbortError') return this.delay(5000); + // If the error is an HTTPError, delay for 30 seconds: + if (error instanceof HTTPError) return this.delay(30000); + // If the error is an UnknownChannel error, ignore: + if (error instanceof DiscordAPIError && IgnoredGuildErrors.includes(error.code as number)) return this.finish(); + // Otherwise, emit the error: + this.container.logger.error(getLogPrefix(this), error); + return this.finish(); + } +} + +type SupportedChannel = + | CategoryChannel + | NewsChannel + | StageChannel + | TextChannel + | PrivateThreadChannel + | PublicThreadChannel + | VoiceChannel + | ForumChannel + | MediaChannel; +type SupportedThreadChannel = AnyThreadChannel; +type SupportedNonThreadChannel = Exclude; diff --git a/vitest.config.ts b/vitest.config.ts index 7f16e09af51..eef9a9a3dd4 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -45,43 +45,23 @@ export default defineConfig({ include: ['src/lib/**'], exclude: [ 'src/lib/api', - 'src/lib/database/entities', 'src/lib/database/index.ts', - 'src/lib/database/migrations', - 'src/lib/database/repositories', 'src/lib/database/settings', 'src/lib/database/utils', 'src/lib/discord', - 'src/lib/env', - 'src/lib/extensions', 'src/lib/games/base', 'src/lib/games/connect-four', 'src/lib/games/HungerGamesUsage.ts', 'src/lib/games/Slotmachine.ts', 'src/lib/games/tic-tac-toe', - 'src/lib/games/WheelOfFortune.ts', - 'src/lib/i18n/structures/Augments.d.ts', 'src/lib/moderation', - 'src/lib/setup/PaginatedMessage.ts', 'src/lib/SkyraClient.ts', 'src/lib/structures', 'src/lib/types', - 'src/lib/util/APIs', - 'src/lib/util/Color.ts', - 'src/lib/util/decorators.ts', 'src/lib/util/External', - 'src/lib/util/Leaderboard.ts', 'src/lib/util/Links', 'src/lib/util/LongLivingReactionCollector.ts', - 'src/lib/util/Models', - 'src/lib/util/Notifications', - 'src/lib/util/Parsers', - 'src/lib/util/PreciseTimeout.ts', - 'src/lib/util/PromptList.ts', - 'src/lib/util/Security/GuildSecurity.ts', - 'src/lib/util/Security/ModerationActions.ts', - 'src/lib/util/Timers.ts', - 'src/lib/weather' + 'src/lib/util/Parsers' ] } },