/[sudobot]/branches/7.x/src/core/Command.ts
ViewVC logotype

Contents of /branches/7.x/src/core/Command.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (show annotations)
Mon Jul 29 18:52:37 2024 UTC (8 months ago) by rakinar2
File MIME type: application/typescript
File size: 18613 byte(s)
chore: add old version archive branches (2.x to 9.x-dev)
1 /*
2 * This file is part of SudoBot.
3 *
4 * Copyright (C) 2021-2023 OSN Developers.
5 *
6 * SudoBot is free software; you can redistribute it and/or modify it
7 * under the terms of the GNU Affero General Public License as published by
8 * the Free Software Foundation, either version 3 of the License, or
9 * (at your option) any later version.
10 *
11 * SudoBot is distributed in the hope that it will be useful, but
12 * WITHOUT ANY WARRANTY; without even the implied warranty of
13 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
14 * GNU Affero General Public License for more details.
15 *
16 * You should have received a copy of the GNU Affero General Public License
17 * along with SudoBot. If not, see <https://www.gnu.org/licenses/>.
18 */
19
20 import {
21 APIEmbed,
22 APIEmbedField,
23 APIMessage,
24 ApplicationCommandType,
25 Awaitable,
26 CacheType,
27 Channel,
28 ChatInputCommandInteraction,
29 ContextMenuCommandBuilder,
30 ContextMenuCommandInteraction,
31 GuildMember,
32 InteractionDeferReplyOptions,
33 InteractionEditReplyOptions,
34 InteractionReplyOptions,
35 Message,
36 MessageCreateOptions,
37 MessagePayload,
38 ModalSubmitInteraction,
39 PermissionResolvable,
40 SlashCommandBuilder,
41 TextBasedChannel,
42 User
43 } from "discord.js";
44 import { ChatInputCommandContext, ContextMenuCommandContext, LegacyCommandContext } from "../services/CommandManager";
45 import EmbedSchemaParser from "../utils/EmbedSchemaParser";
46 import { channelInfo, guildInfo, userInfo } from "../utils/embed";
47 import { safeChannelFetch } from "../utils/fetch";
48 import { logError } from "../utils/logger";
49 import { getEmoji } from "../utils/utils";
50 import Client from "./Client";
51 import CommandArgumentParser from "./CommandArgumentParser";
52 import { ValidationRule } from "./CommandArgumentParserInterface";
53
54 export * from "./CommandArgumentParserInterface";
55
56 export type CommandMessage = Message<boolean> | ChatInputCommandInteraction<CacheType> | ContextMenuCommandInteraction;
57 export type BasicCommandContext = LegacyCommandContext | ChatInputCommandContext;
58 export type AnyCommandContext = BasicCommandContext | ContextMenuCommandContext;
59 export type CommandReturn =
60 | ((MessageCreateOptions | APIMessage | InteractionReplyOptions) & { __reply?: boolean })
61 | undefined
62 | null
63 | void;
64
65 type CommandSlashCommandBuilder =
66 | Partial<Pick<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">>
67 | Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">;
68
69 type DeferReplyMode = "delete" | "channel" | "default" | "auto";
70 type DeferReplyOptions =
71 | ((MessageCreateOptions | MessagePayload | InteractionEditReplyOptions) & { ephemeral?: boolean })
72 | string;
73 type PermissionValidationResult =
74 | boolean
75 | {
76 isPermitted: boolean;
77 source?: string;
78 };
79 export type RunCommandOptions = {
80 message: CommandMessage;
81 context: AnyCommandContext;
82 onAbort?: () => unknown;
83 checkOnly?: boolean;
84 };
85
86 class PermissionError extends Error {}
87
88 export default abstract class Command {
89 public readonly name: string = "";
90 public group: string = "Default";
91 public readonly aliases: string[] = [];
92
93 public readonly supportsInteractions: boolean = true;
94 public readonly supportsLegacy: boolean = true;
95
96 public readonly permissions: PermissionResolvable[] = [];
97 public readonly validationRules: readonly ValidationRule[] = [];
98 public readonly permissionMode: "or" | "and" = "and";
99 public readonly systemAdminOnly: boolean = false;
100
101 public readonly description?: string;
102 public readonly detailedDescription?: string;
103 public readonly argumentSyntaxes?: string[];
104 public readonly availableOptions?: Record<string, string>;
105 public readonly beta: boolean = false;
106 public readonly since: string = "1.0.0";
107 public readonly botRequiredPermissions: PermissionResolvable[] = [];
108 public readonly slashCommandBuilder?: CommandSlashCommandBuilder;
109
110 public readonly applicationCommandType: ApplicationCommandType = ApplicationCommandType.ChatInput;
111 public readonly otherApplicationCommandBuilders: (ContextMenuCommandBuilder | SlashCommandBuilder)[] = [];
112
113 public readonly subcommands: string[] = [];
114 public readonly subCommandCheck: boolean = false;
115 public readonly cooldown?: number = undefined;
116
117 protected message?: CommandMessage;
118
119 protected static argumentParser = new CommandArgumentParser(Client.instance);
120
121 protected constructor(protected client: Client<true>) {}
122
123 abstract execute(message: CommandMessage, context: AnyCommandContext, options?: RunCommandOptions): Promise<CommandReturn>;
124
125 protected getCommandMessage(): CommandMessage {
126 if (!this.message) {
127 throw new TypeError(`${this.constructor.name}.message is undefined`);
128 }
129
130 return this.message;
131 }
132
133 protected async deferIfInteraction(message: CommandMessage, options?: InteractionDeferReplyOptions) {
134 if (message instanceof ChatInputCommandInteraction) return await message.deferReply(options).catch(logError);
135 }
136
137 public async deferredReply(message: CommandMessage, options: DeferReplyOptions, mode: DeferReplyMode = "default") {
138 if (message instanceof ChatInputCommandInteraction || message instanceof ContextMenuCommandInteraction) {
139 return message.deferred ? await message.editReply(options) : await message.reply(options as any);
140 }
141
142 const behaviour = this.client.configManager.config[message.guildId!]?.commands.moderation_command_behaviour;
143
144 if (mode === "delete" || (mode === "auto" && behaviour === "delete")) {
145 await message.delete().catch(logError);
146 }
147
148 return mode === "delete" || (mode === "auto" && behaviour === "delete") || mode === "channel"
149 ? message.channel.send(options as any)
150 : message.reply(options as any);
151 }
152
153 protected async error(message: CommandMessage, errorMessage?: string, mode: DeferReplyMode = "default") {
154 return await this.deferredReply(
155 message,
156 errorMessage
157 ? `${this.emoji("error")} ${errorMessage}`
158 : `⚠️ An error has occurred while performing this action. Please make sure that the bot has the required permissions to perform this action.`,
159 mode
160 );
161 }
162
163 protected async success(message: CommandMessage, successMessage?: string, mode: DeferReplyMode = "default") {
164 return await this.deferredReply(
165 message,
166 successMessage ? `${this.emoji("check")} ${successMessage}` : `Successfully completed the given task.`,
167 mode
168 );
169 }
170
171 protected sendError(errorMessage?: string, mode: DeferReplyMode = "default") {
172 return this.error(this.getCommandMessage(), errorMessage, mode);
173 }
174
175 protected sendSuccess(successMessage?: string, mode: DeferReplyMode = "default") {
176 return this.success(this.getCommandMessage(), successMessage, mode);
177 }
178
179 protected emoji(name: string) {
180 return getEmoji(this.client, name);
181 }
182
183 protected async sendCommandRanLog(
184 message: CommandMessage | ModalSubmitInteraction,
185 options: APIEmbed,
186 params: {
187 fields?: (fields: APIEmbedField[]) => Awaitable<APIEmbedField[]>;
188 before?: (channel: TextBasedChannel, sentMessages: Array<Message | null>) => any;
189 previews?: Array<MessageCreateOptions | MessagePayload>;
190 url?: string | null;
191 } = {}
192 ) {
193 if (!this.client.configManager.systemConfig.logging?.enabled) {
194 return;
195 }
196
197 const { previews = [] } = params;
198 const logChannelId = this.client.configManager.systemConfig.logging?.channels?.echo_send_logs;
199
200 if (!logChannelId) {
201 return;
202 }
203
204 try {
205 const channel = await safeChannelFetch(await this.client.getHomeGuild(), logChannelId);
206
207 if (!channel?.isTextBased()) {
208 return;
209 }
210
211 const sentMessages = [];
212
213 for (const preview of previews) {
214 const sentMessage = await EmbedSchemaParser.sendMessage(channel, {
215 ...preview,
216 reply: undefined
217 } as MessageCreateOptions).catch(logError);
218 sentMessages.push(sentMessage ?? null);
219 }
220
221 (await params.before?.(channel, sentMessages))?.catch?.(logError);
222
223 const embedFields = [
224 {
225 name: "Command Name",
226 value: this.name
227 },
228 {
229 name: "Guild Info",
230 value: guildInfo(message.guild!),
231 inline: true
232 },
233 {
234 name: "Channel Info",
235 value: channelInfo(message.channel as Channel),
236 inline: true
237 },
238 {
239 name: "User (Executor)",
240 value: userInfo(message.member!.user as User),
241 inline: true
242 },
243 {
244 name: "Mode",
245 value: message instanceof Message ? "Legacy" : "Application Interaction"
246 },
247 ...(params.url !== null
248 ? [
249 {
250 name: "Message URL",
251 value: params.url ?? (message instanceof Message ? message.url : "*Not available*")
252 }
253 ]
254 : [])
255 ];
256
257 await channel
258 ?.send({
259 embeds: [
260 {
261 author: {
262 name: message.member?.user.username as string,
263 icon_url: (message.member?.user as User)?.displayAvatarURL()
264 },
265 title: "A command was executed",
266 color: 0x007bff,
267 fields: (await params.fields?.(embedFields)) ?? embedFields,
268 ...options
269 }
270 ]
271 })
272 .catch(logError);
273 } catch (error) {
274 logError(error);
275 }
276 }
277
278 /**
279 * Check for command cooldowns.
280 *
281 * @param message The target message
282 * @returns {Promise<boolean>} Whether to abort the command
283 */
284 private async cooldownCheck(message: CommandMessage): Promise<boolean> {
285 if (!this.cooldown) {
286 return false;
287 }
288
289 const { cooldown, enabled } = this.client.cooldown.lock(message.guildId!, this.name, this.cooldown);
290
291 if (enabled) {
292 const seconds = Math.max(Math.ceil(((cooldown ?? 0) - Date.now()) / 1000), 1);
293
294 await this.deferredReply(message, {
295 embeds: [
296 {
297 description: `${this.emoji(
298 "clock_red"
299 )} You're being rate limited, please wait for **${seconds}** second${seconds === 1 ? "" : "s"}.`,
300 color: 0xf14a60
301 }
302 ],
303 ephemeral: true
304 });
305
306 return true;
307 }
308
309 return false;
310 }
311
312 protected async validateCommandBuiltInPermissions(member: GuildMember, hasOverwrite: boolean): Promise<boolean> {
313 if (
314 this.client.configManager.systemConfig.default_permissions_mode === "ignore" ||
315 (hasOverwrite && this.client.configManager.systemConfig.default_permissions_mode === "overwrite")
316 ) {
317 return true;
318 }
319
320 return (
321 (this.permissionMode === "and" && member.permissions.has(this.permissions, true)) ||
322 (this.permissionMode === "or" && member.permissions.any(this.permissions, true))
323 );
324 }
325
326 protected async validateNonSystemAdminPermissions({ message }: RunCommandOptions) {
327 const commandName = this.name;
328 const { disabled_commands } = this.client.configManager.systemConfig;
329
330 if (disabled_commands.includes(commandName ?? "")) {
331 throw new PermissionError("This command is disabled.");
332 }
333
334 const { channels, guild } = this.client.configManager.config[message.guildId!]?.disabled_commands ?? {};
335
336 if (guild && guild.includes(commandName ?? "")) {
337 throw new PermissionError("This command is disabled in this server.");
338 }
339
340 if (channels && channels[message.channelId!] && channels[message.channelId!].includes(commandName ?? "")) {
341 throw new PermissionError("This command is disabled in this channel.");
342 }
343
344 return true;
345 }
346
347 protected validateSystemAdminPermissions(member: GuildMember) {
348 const isSystemAdmin = this.client.configManager.systemConfig.system_admins.includes(member.id);
349
350 if (this.systemAdminOnly && !isSystemAdmin) {
351 throw new PermissionError("This command is only available to system administrators.");
352 }
353
354 return {
355 isSystemAdmin
356 };
357 }
358
359 protected async validatePermissions(
360 options: RunCommandOptions
361 ): Promise<{ result?: PermissionValidationResult; abort?: boolean; error?: string }> {
362 const { message } = options;
363
364 try {
365 const { isSystemAdmin } = this.validateSystemAdminPermissions(message.member as GuildMember);
366
367 if (isSystemAdmin) {
368 return { result: true };
369 }
370
371 if (this.client.commandManager.isBanned(message.member!.user.id)) {
372 return { abort: true };
373 }
374
375 const builtInValidationResult = await this.validateCommandBuiltInPermissions(
376 message.member as GuildMember,
377 !!this.client.commandPermissionOverwriteManager.permissionOverwrites.get(`${message.guildId!}____${this.name}`)
378 ?.length
379 );
380
381 if (!builtInValidationResult) {
382 return { result: false };
383 }
384
385 const { result: permissionOverwriteResult } =
386 await this.client.commandPermissionOverwriteManager.validatePermissionOverwrites({
387 commandName: this.name,
388 guildId: message.guildId!,
389 member: message.member as GuildMember,
390 channelId: message.channelId!
391 });
392
393 if (!permissionOverwriteResult) {
394 return {
395 error: "You don't have enough permissions to run this command."
396 };
397 }
398
399 return {
400 result: await this.validateNonSystemAdminPermissions(options)
401 };
402 } catch (error) {
403 if (error instanceof PermissionError) {
404 return {
405 error: error.message
406 };
407 }
408
409 logError(error);
410
411 return {
412 abort: true
413 };
414 }
415 }
416
417 protected async doChecks(options: RunCommandOptions) {
418 const { message } = options;
419 const { result: permissionValidationResult, abort, error } = await this.validatePermissions(options);
420
421 if (abort) {
422 return;
423 }
424
425 const isPermitted =
426 (typeof permissionValidationResult === "boolean" && permissionValidationResult) ||
427 (typeof permissionValidationResult === "object" && permissionValidationResult.isPermitted);
428
429 if (!isPermitted || error) {
430 const permissionValidationFailureSource =
431 (typeof permissionValidationResult === "object" && permissionValidationResult.source) || undefined;
432 const debugMode =
433 (this.client.configManager.systemConfig.debug_mode ||
434 this.client.configManager.config[message.guildId!]?.debug_mode) ??
435 false;
436
437 await this.error(
438 message,
439 `${error ?? "You don't have permission to run this command."}${
440 debugMode && permissionValidationFailureSource ? `\nSource: \`${permissionValidationFailureSource}\`` : ""
441 }`
442 );
443
444 return false;
445 }
446
447 if (this.cooldown) {
448 const abort = await this.cooldownCheck(message);
449
450 if (abort) {
451 return;
452 }
453 }
454
455 return true;
456 }
457
458 protected async parseArguments({ message, context }: RunCommandOptions) {
459 if (!(message instanceof Message) || !context.isLegacy) {
460 return true;
461 }
462
463 const { error, parsedArgs } = await Command.argumentParser.parse({
464 message,
465 input: message.content,
466 prefix: context.prefix,
467 rules: this.validationRules
468 });
469
470 if (error) {
471 await this.error(message, error);
472 return false;
473 }
474
475 if (context.isLegacy && this.subCommandCheck && !this.subcommands.includes(context.args[0])) {
476 await this.error(
477 message,
478 `Please provide a valid subcommand! The valid subcommands are \`${this.subcommands.join("`, `")}\`.`
479 );
480
481 return false;
482 }
483
484 return parsedArgs;
485 }
486
487 async run(options: RunCommandOptions) {
488 const { message, context, checkOnly = false, onAbort } = options;
489
490 this.message = message;
491
492 if (!(await this.doChecks(options))) {
493 this.message = undefined;
494 onAbort?.();
495 return;
496 }
497
498 const parsedArgs = await this.parseArguments(options);
499
500 if (parsedArgs === false) {
501 this.message = undefined;
502 onAbort?.();
503 return;
504 }
505
506 if (checkOnly) {
507 this.message = undefined;
508 return;
509 }
510
511 if (typeof parsedArgs === "object" && context.isLegacy) {
512 context.parsedArgs = [];
513
514 for (const key in parsedArgs) {
515 context.parsedArgs[key as unknown as number] = parsedArgs[key];
516 }
517
518 context.parsedNamedArgs = parsedArgs;
519 }
520
521 const commandReturn = await this.execute(message, context);
522 this.message = undefined;
523 return commandReturn;
524 }
525 }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26