Skip to content

Commit aee40dd

Browse files
committed
refactor: rewrite lockdown command
1 parent 26f8449 commit aee40dd

File tree

5 files changed

+308
-91
lines changed

5 files changed

+308
-91
lines changed
Lines changed: 251 additions & 90 deletions
Original file line numberDiff line numberDiff line change
@@ -1,129 +1,290 @@
11
import { LanguageKeys } from '#lib/i18n/languageKeys';
2-
import { LockdownManager, SkyraSubcommand } from '#lib/structures';
2+
import { getSupportedUserLanguageT } from '#lib/i18n/translate';
3+
import { SkyraCommand } from '#lib/structures';
34
import { PermissionLevels, type GuildMessage } from '#lib/types';
4-
import { clearAccurateTimeout, setAccurateTimeout } from '#utils/Timers';
5-
import { floatPromise } from '#utils/common';
6-
import { assertNonThread, getSecurity } from '#utils/functions';
5+
import { PermissionsBits } from '#utils/bits.js';
6+
import { toErrorCodeResult } from '#utils/common';
7+
import { getCodeStyle, getLogPrefix } from '#utils/functions';
8+
import { resolveTimeSpan } from '#utils/resolvers';
9+
import { getTag } from '#utils/util.js';
710
import { ApplyOptions } from '@sapphire/decorators';
8-
import { canSendMessages, type NonThreadGuildTextBasedChannelTypes } from '@sapphire/discord.js-utilities';
9-
import { CommandOptionsRunTypeEnum } from '@sapphire/framework';
11+
import { ApplicationCommandRegistry, CommandOptionsRunTypeEnum, ok } from '@sapphire/framework';
1012
import { send } from '@sapphire/plugin-editable-commands';
11-
import type { TFunction } from '@sapphire/plugin-i18next';
12-
import { PermissionFlagsBits, type Role } from 'discord.js';
13+
import { applyLocalizedBuilder, createLocalizedChoice, type TFunction } from '@sapphire/plugin-i18next';
14+
import { Time } from '@sapphire/time-utilities';
15+
import { isNullish } from '@sapphire/utilities';
16+
import {
17+
ChannelType,
18+
ChatInputCommandInteraction,
19+
MessageFlags,
20+
PermissionFlagsBits,
21+
RESTJSONErrorCodes,
22+
Role,
23+
User,
24+
channelMention,
25+
chatInputApplicationCommandMention,
26+
type CommandInteractionOption,
27+
type ThreadChannelType
28+
} from 'discord.js';
1329

14-
@ApplyOptions<SkyraSubcommand.Options>({
30+
const Root = LanguageKeys.Commands.Lockdown;
31+
32+
@ApplyOptions<SkyraCommand.Options>({
1533
aliases: ['lock', 'unlock'],
1634
description: LanguageKeys.Commands.Moderation.LockdownDescription,
1735
detailedDescription: LanguageKeys.Commands.Moderation.LockdownExtended,
1836
permissionLevel: PermissionLevels.Moderator,
1937
requiredClientPermissions: [PermissionFlagsBits.ManageChannels, PermissionFlagsBits.ManageRoles],
20-
runIn: [CommandOptionsRunTypeEnum.GuildAny],
21-
subcommands: [
22-
{ name: 'lock', messageRun: 'lock' },
23-
{ name: 'unlock', messageRun: 'unlock' },
24-
{ name: 'auto', messageRun: 'auto', default: true }
25-
]
38+
runIn: [CommandOptionsRunTypeEnum.GuildAny]
2639
})
27-
export class UserCommand extends SkyraSubcommand {
28-
public override messageRun(message: GuildMessage, args: SkyraSubcommand.Args, context: SkyraSubcommand.RunContext) {
29-
if (context.commandName === 'lock') return this.lock(message, args);
30-
if (context.commandName === 'unlock') return this.unlock(message, args);
31-
return super.messageRun(message, args, context);
40+
export class UserCommand extends SkyraCommand {
41+
public override messageRun(message: GuildMessage, args: SkyraCommand.Args) {
42+
const content = args.t(LanguageKeys.Commands.Shared.DeprecatedMessage, {
43+
command: chatInputApplicationCommandMention(this.name, this.getGlobalCommandId())
44+
});
45+
return send(message, { content });
3246
}
3347

34-
public async auto(message: GuildMessage, args: SkyraSubcommand.Args) {
35-
const role = await args.pick('roleName').catch(() => message.guild.roles.everyone);
36-
const channel = args.finished ? assertNonThread(message.channel) : await args.pick('textChannelName');
37-
if (this.getLock(role, channel)) return this.handleUnlock(message, args, role, channel);
48+
public override chatInputRun(interaction: Interaction) {
49+
const durationRaw = interaction.options.getString('duration');
50+
const durationResult = this.#parseDuration(durationRaw);
51+
const t = getSupportedUserLanguageT(interaction);
52+
if (durationResult?.isErr()) {
53+
const content = t(durationResult.unwrapErr(), { parameter: durationRaw! });
54+
return interaction.reply({ content, flags: MessageFlags.Ephemeral });
55+
}
3856

39-
const duration = args.finished ? null : await args.pick('timespan', { minimum: 0 });
40-
return this.handleLock(message, args, role, channel, duration);
57+
const duration = durationResult.unwrap();
58+
const global = interaction.options.getBoolean('global') ?? false;
59+
const channel =
60+
interaction.options.getChannel<SupportedChannelType>('channel') ?? (global ? null : (interaction.channel as SupportedChannel));
61+
const role = interaction.options.getRole('role') ?? interaction.guild!.roles.everyone;
62+
const action = interaction.options.getString('action', true)! as 'lock' | 'unlock';
63+
return action === 'lock' //
64+
? this.#lock(t, interaction.user, channel, role, duration)
65+
: this.#unlock(t, interaction.user, channel, role);
4166
}
4267

43-
public async unlock(message: GuildMessage, args: SkyraSubcommand.Args) {
44-
const role = await args.pick('roleName').catch(() => message.guild.roles.everyone);
45-
const channel = args.finished ? assertNonThread(message.channel) : await args.pick('textChannelName');
46-
return this.handleUnlock(message, args, role, channel);
68+
public override registerApplicationCommands(registry: ApplicationCommandRegistry) {
69+
registry.registerChatInputCommand((builder) =>
70+
applyLocalizedBuilder(builder, Root.Name, Root.Description) //
71+
.addStringOption((option) =>
72+
applyLocalizedBuilder(option, Root.Action)
73+
.setRequired(true)
74+
.addChoices(
75+
createLocalizedChoice(Root.ActionLock, { value: 'lock' }),
76+
createLocalizedChoice(Root.ActionUnlock, { value: 'unlock' })
77+
)
78+
)
79+
.addRoleOption((option) => applyLocalizedBuilder(option, Root.Role))
80+
.addChannelOption((option) => applyLocalizedBuilder(option, Root.Channel))
81+
.addStringOption((option) => applyLocalizedBuilder(option, Root.Duration))
82+
.setDefaultMemberPermissions(PermissionFlagsBits.ManageChannels | PermissionFlagsBits.ManageRoles)
83+
.setDMPermission(false)
84+
);
4785
}
4886

49-
public async lock(message: GuildMessage, args: SkyraSubcommand.Args) {
50-
const role = await args.pick('roleName').catch(() => message.guild.roles.everyone);
51-
const channel = args.finished ? assertNonThread(message.channel) : await args.pick('textChannelName');
52-
const duration = args.finished ? null : await args.pick('timespan', { minimum: 0 });
53-
return this.handleLock(message, args, role, channel, duration);
87+
#lock(t: TFunction, user: User, channel: SupportedChannel | null, role: Role, duration: number | null) {
88+
return isNullish(channel)
89+
? this.#lockGuild(t, user, role, duration)
90+
: UserCommand.ThreadChannelTypes.includes(channel.type)
91+
? this.#lockThread(t, user, channel as SupportedThreadChannel, duration)
92+
: this.#lockChannel(t, user, channel as SupportedNonThreadChannel, role, duration);
5493
}
5594

56-
private async handleLock(
57-
message: GuildMessage,
58-
args: SkyraSubcommand.Args,
59-
role: Role,
60-
channel: NonThreadGuildTextBasedChannelTypes,
61-
duration: number | null
62-
) {
63-
// If there was a lockdown, abort lock
64-
const lock = this.getLock(role, channel);
65-
if (lock !== null) {
66-
this.error(LanguageKeys.Commands.Moderation.LockdownLocked, { channel: channel.toString() });
95+
async #lockGuild(t: TFunction, user: User, role: Role, duration: number | null) {
96+
void t;
97+
void user;
98+
void role;
99+
void duration;
100+
101+
if (!role.permissions.has(PermissionFlagsBits.SendMessages | PermissionFlagsBits.SendMessagesInThreads)) {
102+
return t(Root.GuildLocked, { role: role.toString() });
67103
}
68104

69-
const allowed = this.isAllowed(role, channel);
105+
const reason = t(Root.AuditLogRequestedBy, { user: getTag(user), role: role.toString() });
106+
const result = await toErrorCodeResult(
107+
role.setPermissions(
108+
PermissionsBits.difference(role.permissions.bitfield, PermissionFlagsBits.SendMessages | PermissionFlagsBits.SendMessagesInThreads),
109+
reason
110+
)
111+
);
112+
return result.match({
113+
ok: () => this.#lockGuildOk(t, role),
114+
err: (code) => this.#lockGuildErr(t, role, code)
115+
});
116+
}
117+
118+
#lockGuildOk(t: TFunction, role: Role) {
119+
return t(Root.SuccessGuild, { role: role.toString() });
120+
}
121+
122+
#lockGuildErr(t: TFunction, role: Role, code: RESTJSONErrorCodes) {
123+
if (code === RESTJSONErrorCodes.UnknownRole) return t(Root.GuildUnknownRole, { role: role.toString() });
124+
125+
this.container.logger.error(`${getLogPrefix(this)} ${getCodeStyle(code)} Could not lock the guild ${role.id}`);
126+
return t(Root.GuildLockFailed, { role: role.toString() });
127+
}
128+
129+
async #lockThread(t: TFunction, user: User, channel: SupportedThreadChannel, duration: number | null) {
130+
void duration;
131+
132+
if (channel.locked) {
133+
return t(Root.ThreadLocked, { channel: channelMention(channel.id) });
134+
}
70135

71-
// If they can send, begin locking
72-
const response = await send(message, args.t(LanguageKeys.Commands.Moderation.LockdownLocking, { channel: channel.toString() }));
73-
await channel.permissionOverwrites.edit(role, { SendMessages: false });
74-
if (canSendMessages(message.channel)) {
75-
await response.edit(args.t(LanguageKeys.Commands.Moderation.LockdownLock, { channel: channel.toString() })).catch(() => null);
136+
if (!channel.manageable) {
137+
return t(Root.ThreadUnmanageable, { channel: channelMention(channel.id) });
76138
}
77139

78-
// Create the timeout
79-
const timeout = duration
80-
? setAccurateTimeout(() => floatPromise(this.performUnlock(message, args.t, role, channel, allowed)), duration)
81-
: null;
82-
getSecurity(message.guild).lockdowns.add(role, channel, { allowed, timeout });
140+
const reason = t(Root.AuditLogRequestedBy, { user: getTag(user), channel: channelMention(channel.id) });
141+
const result = await toErrorCodeResult(channel.setLocked(true, reason));
142+
return result.match({
143+
ok: () => this.#lockThreadOk(t, channel),
144+
err: (code) => this.#lockThreadErr(t, channel, code)
145+
});
83146
}
84147

85-
private isAllowed(role: Role, channel: NonThreadGuildTextBasedChannelTypes): boolean | null {
86-
return channel.permissionOverwrites.cache.get(role.id)?.allow.has(PermissionFlagsBits.SendMessages, false) ?? null;
148+
#lockThreadOk(t: TFunction, channel: SupportedThreadChannel) {
149+
return t(Root.SuccessThread, { channel: channelMention(channel.id) });
87150
}
88151

89-
private async handleUnlock(message: GuildMessage, args: SkyraSubcommand.Args, role: Role, channel: NonThreadGuildTextBasedChannelTypes) {
90-
const entry = this.getLock(role, channel);
91-
if (entry === null) this.error(LanguageKeys.Commands.Moderation.LockdownUnlocked, { channel: channel.toString() });
92-
if (entry.timeout) clearAccurateTimeout(entry.timeout);
93-
return this.performUnlock(message, args.t, role, channel, entry.allowed);
152+
#lockThreadErr(t: TFunction, channel: SupportedThreadChannel, code: RESTJSONErrorCodes) {
153+
if (code === RESTJSONErrorCodes.UnknownChannel) return t(Root.ThreadUnknownChannel, { channel: channelMention(channel.id) });
154+
155+
this.container.logger.error(`${getLogPrefix(this)} ${getCodeStyle(code)} Could not lock the thread ${channel.id}`);
156+
return t(Root.ThreadLockFailed, { channel: channelMention(channel.id) });
94157
}
95158

96-
private async performUnlock(
97-
message: GuildMessage,
98-
t: TFunction,
99-
role: Role,
100-
channel: NonThreadGuildTextBasedChannelTypes,
101-
allowed: boolean | null
102-
) {
103-
getSecurity(channel.guild).lockdowns.remove(role, channel);
104-
105-
const overwrites = channel.permissionOverwrites.cache.get(role.id);
106-
if (overwrites === undefined) return;
107-
108-
// If the only permission overwrite is the denied SEND_MESSAGES, clean up the entire permission; if the permission
109-
// was denied, reset it to the default state, otherwise don't run an extra query
110-
if (overwrites.allow.bitfield === 0n && overwrites.deny.bitfield === PermissionFlagsBits.SendMessages) {
111-
await overwrites.delete();
112-
} else if (overwrites.deny.has(PermissionFlagsBits.SendMessages)) {
113-
await overwrites.edit({ SendMessages: allowed });
159+
async #lockChannel(t: TFunction, user: User, channel: SupportedNonThreadChannel, role: Role, duration: number | null) {
160+
void duration;
161+
162+
if (!channel.permissionsFor(role).has(PermissionFlagsBits.SendMessages | PermissionFlagsBits.SendMessagesInThreads)) {
163+
return t(Root.ChannelLocked, { channel: channelMention(channel.id) });
114164
}
115165

116-
if (canSendMessages(message.channel)) {
117-
const content = t(LanguageKeys.Commands.Moderation.LockdownOpen, { channel: channel.toString() });
118-
await send(message, content);
166+
if (!channel.manageable) {
167+
return t(Root.ChannelUnmanageable, { channel: channelMention(channel.id) });
119168
}
169+
170+
const reason = t(Root.AuditLogRequestedBy, { user: getTag(user), channel: channelMention(channel.id) });
171+
const result = await toErrorCodeResult(
172+
channel.permissionOverwrites.edit(role, { SendMessages: false, SendMessagesInThreads: false }, { reason })
173+
);
174+
return result.match({
175+
ok: () => this.#lockChannelOk(t, channel),
176+
err: (code) => this.#lockChannelErr(t, channel, code)
177+
});
178+
}
179+
180+
#lockChannelOk(t: TFunction, channel: SupportedNonThreadChannel) {
181+
return t(Root.SuccessChannel, { channel: channelMention(channel.id) });
182+
}
183+
184+
#lockChannelErr(t: TFunction, channel: SupportedNonThreadChannel, code: RESTJSONErrorCodes) {
185+
if (code === RESTJSONErrorCodes.UnknownChannel) return t(Root.ChannelUnknownChannel, { channel: channelMention(channel.id) });
186+
187+
this.container.logger.error(`${getLogPrefix(this)} ${getCodeStyle(code)} Could not lock the channel ${channel.id}`);
188+
return t(Root.ChannelLockFailed, { channel: channelMention(channel.id) });
120189
}
121190

122-
private getLock(role: Role, channel: NonThreadGuildTextBasedChannelTypes): LockdownManager.Entry | null {
123-
const entry = getSecurity(channel.guild).lockdowns.get(channel.id)?.get(role.id);
124-
if (entry) return entry;
191+
#unlock(t: TFunction, user: User, channel: SupportedChannel | null, role: Role) {
192+
void t;
193+
void user;
194+
void role;
195+
void channel;
196+
}
197+
198+
// private async handleLock(
199+
// message: GuildMessage,
200+
// args: SkyraCommand.Args,
201+
// role: Role,
202+
// channel: NonThreadGuildTextBasedChannelTypes,
203+
// duration: number | null
204+
// ) {
205+
// // If there was a lockdown, abort lock
206+
// const lock = this.getLock(role, channel);
207+
// if (lock !== null) {
208+
// this.error(LanguageKeys.Commands.Moderation.LockdownLocked, { channel: channel.toString() });
209+
// }
210+
211+
// const allowed = this.isAllowed(role, channel);
212+
213+
// // If they can send, begin locking
214+
// const response = await send(message, args.t(LanguageKeys.Commands.Moderation.LockdownLocking, { channel: channel.toString() }));
215+
// await channel.permissionOverwrites.edit(role, { SendMessages: false });
216+
// if (canSendMessages(message.channel)) {
217+
// await response.edit(args.t(LanguageKeys.Commands.Moderation.LockdownLock, { channel: channel.toString() })).catch(() => null);
218+
// }
219+
220+
// // Create the timeout
221+
// const timeout = duration
222+
// ? setAccurateTimeout(() => floatPromise(this.performUnlock(message, args.t, role, channel, allowed)), duration)
223+
// : null;
224+
// getSecurity(message.guild).lockdowns.add(role, channel, { allowed, timeout });
225+
// }
226+
227+
// private isAllowed(role: Role, channel: NonThreadGuildTextBasedChannelTypes): boolean | null {
228+
// return channel.permissionOverwrites.cache.get(role.id)?.allow.has(PermissionFlagsBits.SendMessages, false) ?? null;
229+
// }
230+
231+
// private async handleUnlock(message: GuildMessage, args: SkyraCommand.Args, role: Role, channel: NonThreadGuildTextBasedChannelTypes) {
232+
// const entry = this.getLock(role, channel);
233+
// if (entry === null) this.error(LanguageKeys.Commands.Moderation.LockdownUnlocked, { channel: channel.toString() });
234+
// if (entry.timeout) clearAccurateTimeout(entry.timeout);
235+
// return this.performUnlock(message, args.t, role, channel, entry.allowed);
236+
// }
125237

126-
const permissions = channel.permissionOverwrites.cache.get(role.id)?.deny.has(PermissionFlagsBits.SendMessages);
127-
return permissions === true ? { allowed: null, timeout: null } : null;
238+
// private async performUnlock(
239+
// message: GuildMessage,
240+
// t: TFunction,
241+
// role: Role,
242+
// channel: NonThreadGuildTextBasedChannelTypes,
243+
// allowed: boolean | null
244+
// ) {
245+
// getSecurity(channel.guild).lockdowns.remove(role, channel);
246+
247+
// const overwrites = channel.permissionOverwrites.cache.get(role.id);
248+
// if (overwrites === undefined) return;
249+
250+
// // If the only permission overwrite is the denied SEND_MESSAGES, clean up the entire permission; if the permission
251+
// // was denied, reset it to the default state, otherwise don't run an extra query
252+
// if (overwrites.allow.bitfield === 0n && overwrites.deny.bitfield === PermissionFlagsBits.SendMessages) {
253+
// await overwrites.delete();
254+
// } else if (overwrites.deny.has(PermissionFlagsBits.SendMessages)) {
255+
// await overwrites.edit({ SendMessages: allowed });
256+
// }
257+
258+
// if (canSendMessages(message.channel)) {
259+
// const content = t(LanguageKeys.Commands.Moderation.LockdownOpen, { channel: channel.toString() });
260+
// await send(message, content);
261+
// }
262+
// }
263+
264+
// private getLock(role: Role, channel: NonThreadGuildTextBasedChannelTypes): LockdownManager.Entry | null {
265+
// const entry = getSecurity(channel.guild).lockdowns.get(channel.id)?.get(role.id);
266+
// if (entry) return entry;
267+
268+
// const permissions = channel.permissionOverwrites.cache.get(role.id)?.deny.has(PermissionFlagsBits.SendMessages);
269+
// return permissions === true ? { allowed: null, timeout: null } : null;
270+
// }
271+
272+
#parseDuration(value: string | null) {
273+
if (isNullish(value)) return ok(null);
274+
return resolveTimeSpan(value, { minimum: Time.Second * 30, maximum: Time.Year });
128275
}
276+
277+
private static readonly ThreadChannelTypes: ChannelType[] = [
278+
ChannelType.AnnouncementThread,
279+
ChannelType.PublicThread,
280+
ChannelType.PrivateThread
281+
] satisfies readonly SupportedThreadChannelType[];
129282
}
283+
284+
type Interaction = ChatInputCommandInteraction<'cached'>;
285+
286+
type SupportedChannelType = Exclude<ChannelType, ChannelType.DM | ChannelType.GroupDM>;
287+
type SupportedThreadChannelType = Extract<SupportedChannelType, ThreadChannelType>;
288+
type SupportedChannel = Extract<NonNullable<CommandInteractionOption<'cached'>['channel']>, { type: SupportedChannelType }>;
289+
type SupportedThreadChannel = Extract<SupportedChannel, { type: SupportedThreadChannelType }>;
290+
type SupportedNonThreadChannel = Exclude<SupportedChannel, SupportedThreadChannel>;
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
{
2+
"name": "lockdown",
3+
"description": "Manage the server's lockdown status",
4+
"actionName": "action",
5+
"actionDescription": "The action to perform",
6+
"channelName": "channel",
7+
"channelDescription": "The channel to lock down",
8+
"durationName": "duration",
9+
"durationDescription": "How long the lockdown should last",
10+
"roleName": "role",
11+
"roleDescription": "The role to use for the lockdown",
12+
"globalName": "global",
13+
"globalDescription": "⚠️ Whether or not to apply the lockdown to the entire server",
14+
"actionLock": "Lock",
15+
"actionUnlock": "Unlock"
16+
}

0 commit comments

Comments
 (0)