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

Annotation of /branches/8.x/src/core/Command.ts

Parent Directory Parent Directory | Revision Log Revision Log


Revision 577 - (hide annotations)
Mon Jul 29 18:52:37 2024 UTC (8 months ago) by rakinar2
File MIME type: application/typescript
File size: 22006 byte(s)
chore: add old version archive branches (2.x to 9.x-dev)
1 rakinar2 577 /*
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     MessageReplyOptions,
39     ModalSubmitInteraction,
40     PermissionResolvable,
41     SlashCommandBuilder,
42     TextBasedChannel,
43     User,
44     SlashCommandOptionsOnlyBuilder,
45     SlashCommandSubcommandsOnlyBuilder
46     } from "discord.js";
47     import {
48     ChatInputCommandContext,
49     ContextMenuCommandContext,
50     LegacyCommandContext
51     } from "../services/CommandManager";
52     import EmbedSchemaParser from "../utils/EmbedSchemaParser";
53     import { logError } from "../utils/Logger";
54     import { channelInfo, guildInfo, userInfo } from "../utils/embed";
55     import { safeChannelFetch } from "../utils/fetch";
56     import { getEmoji } from "../utils/utils";
57     import Client from "./Client";
58     import CommandArgumentParser from "./CommandArgumentParser";
59     import { ValidationRule } from "./CommandArgumentParserInterface";
60    
61     export * from "./CommandArgumentParserInterface";
62    
63     export type CommandMessage =
64     | Message<boolean>
65     | ChatInputCommandInteraction<CacheType>
66     | ContextMenuCommandInteraction;
67     export type BasicCommandContext = LegacyCommandContext | ChatInputCommandContext;
68     export type AnyCommandContext = BasicCommandContext | ContextMenuCommandContext;
69     export type CommandReturn =
70     | ((MessageCreateOptions | APIMessage | InteractionReplyOptions) & { __reply?: boolean })
71     | undefined
72     | null
73     | void;
74    
75     type CommandSlashCommandBuilder =
76     | Partial<Pick<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">>
77     | Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">
78     | SlashCommandBuilder
79     | SlashCommandOptionsOnlyBuilder
80     | SlashCommandSubcommandsOnlyBuilder;
81    
82     type DeferReplyMode = "delete" | "channel" | "default" | "auto";
83     type DeferReplyOptions =
84     | ((MessageCreateOptions | MessagePayload | InteractionEditReplyOptions) & {
85     ephemeral?: boolean;
86     })
87     | string;
88     type PermissionValidationResult =
89     | boolean
90     | {
91     isPermitted: boolean;
92     source?: string;
93     };
94     export type RunCommandOptions = {
95     message: CommandMessage;
96     context: AnyCommandContext;
97     onAbort?: () => unknown;
98     checkOnly?: boolean;
99     };
100    
101     export class PermissionError extends Error {}
102     type MessageOptions = string | MessageCreateOptions | MessagePayload | InteractionReplyOptions;
103    
104     export class CommandAbortedError extends Error {
105     public readonly options?: MessageOptions | null;
106    
107     constructor(
108     public readonly reason: string,
109     messageOptionsOrCallback?:
110     | MessageOptions
111     | null
112     | (() => MessageOptions | void | undefined | null)
113     ) {
114     super(reason);
115     this.options =
116     typeof messageOptionsOrCallback === "function"
117     ? messageOptionsOrCallback() ?? undefined
118     : messageOptionsOrCallback === undefined
119     ? {
120     content: reason
121     }
122     : messageOptionsOrCallback;
123     }
124    
125     public send(message: CommandMessage) {
126     if (this.options) {
127     return message.reply(this.options as MessagePayload);
128     }
129     }
130     }
131    
132     type SubcommandMeta = {
133     description?: string;
134     detailedDescription?: string;
135     argumentSyntaxes?: string[];
136     availableOptions?: Record<string, string>;
137     beta?: boolean;
138     since?: string;
139     botRequiredPermissions?: PermissionResolvable[];
140     permissions?: PermissionResolvable[];
141     supportsLegacy?: boolean;
142     supportsInteractions?: boolean;
143     systemAdminOnly?: boolean;
144     };
145    
146     export default abstract class Command {
147     public readonly name: string = "";
148     public group: string = "Default";
149     public readonly aliases: string[] = [];
150    
151     public readonly supportsInteractions: boolean = true;
152     public readonly supportsLegacy: boolean = true;
153    
154     public readonly permissions: PermissionResolvable[] = [];
155     public readonly validationRules: readonly ValidationRule[] = [];
156     public readonly permissionMode: "or" | "and" = "and";
157     public readonly systemAdminOnly: boolean = false;
158    
159     public readonly description?: string;
160     public readonly detailedDescription?: string;
161     public readonly argumentSyntaxes?: string[];
162     public readonly availableOptions?: Record<string, string>;
163     public readonly beta: boolean = false;
164     public readonly since: string = "1.0.0";
165     public readonly botRequiredPermissions: PermissionResolvable[] = [];
166     public readonly slashCommandBuilder?: CommandSlashCommandBuilder;
167    
168     public readonly applicationCommandType: ApplicationCommandType =
169     ApplicationCommandType.ChatInput;
170     public readonly otherApplicationCommandBuilders: (
171     | ContextMenuCommandBuilder
172     | SlashCommandBuilder
173     )[] = [];
174    
175     public readonly subcommands: string[] = [];
176     public readonly subCommandCheck: boolean = false;
177     public readonly cooldown?: number = undefined;
178     public readonly subcommandsMeta: Record<string, SubcommandMeta> = {};
179    
180     protected message?: CommandMessage;
181    
182     protected static argumentParser = new CommandArgumentParser(Client.instance);
183    
184     protected constructor(protected client: Client<true>) {}
185    
186     abstract execute(
187     message: CommandMessage,
188     context: AnyCommandContext,
189     options?: RunCommandOptions
190     ): Promise<CommandReturn>;
191    
192     protected getCommandMessage(): CommandMessage {
193     if (!this.message) {
194     throw new TypeError(`${this.constructor.name}.message is undefined`);
195     }
196    
197     return this.message;
198     }
199    
200     protected async deferIfInteraction(
201     message: CommandMessage,
202     options?: InteractionDeferReplyOptions
203     ) {
204     if (message instanceof ChatInputCommandInteraction)
205     return await message.deferReply(options).catch(logError);
206     }
207    
208     public async deferredReply(
209     message: CommandMessage,
210     options: DeferReplyOptions,
211     mode: DeferReplyMode = "default"
212     ) {
213     if (
214     message instanceof ChatInputCommandInteraction ||
215     message instanceof ContextMenuCommandInteraction
216     ) {
217     if (message.deferred) {
218     return message.editReply(options);
219     }
220    
221     const response = await message.reply(options as InteractionReplyOptions);
222     return response.fetch();
223     }
224    
225     const behaviour =
226     this.client.configManager.config[message.guildId!]?.commands
227     .moderation_command_behaviour;
228    
229     if (mode === "delete" || (mode === "auto" && behaviour === "delete")) {
230     await message.delete().catch(logError);
231     }
232    
233     return mode === "delete" ||
234     (mode === "auto" && behaviour === "delete") ||
235     mode === "channel"
236     ? message.channel.send(options as MessageCreateOptions)
237     : message.reply(options as MessageReplyOptions);
238     }
239    
240     protected async error(
241     message: CommandMessage,
242     errorMessage?: string,
243     mode: DeferReplyMode = "default"
244     ) {
245     return await this.deferredReply(
246     message,
247     errorMessage
248     ? `${this.emoji("error")} ${errorMessage}`
249     : "⚠️ An error has occurred while performing this action. Please make sure that the bot has the required permissions to perform this action.",
250     mode
251     );
252     }
253    
254     protected async success(
255     message: CommandMessage,
256     successMessage?: string,
257     mode: DeferReplyMode = "default"
258     ) {
259     return await this.deferredReply(
260     message,
261     successMessage
262     ? `${this.emoji("check")} ${successMessage}`
263     : "Successfully completed the given task.",
264     mode
265     );
266     }
267    
268     protected sendError(errorMessage?: string, mode: DeferReplyMode = "default") {
269     return this.error(this.getCommandMessage(), errorMessage, mode);
270     }
271    
272     protected sendSuccess(successMessage?: string, mode: DeferReplyMode = "default") {
273     return this.success(this.getCommandMessage(), successMessage, mode);
274     }
275    
276     protected abort(
277     options:
278     | string
279     | MessageCreateOptions
280     | MessagePayload
281     | InteractionReplyOptions
282     | null
283     | undefined = null
284     ) {
285     throw new CommandAbortedError(`Command aborted: ${this.name}`, options);
286     }
287    
288     protected emoji(name: string) {
289     return getEmoji(this.client, name);
290     }
291    
292     protected async sendCommandRanLog(
293     message: CommandMessage | ModalSubmitInteraction,
294     options: APIEmbed,
295     params: {
296     fields?: (fields: APIEmbedField[]) => Awaitable<APIEmbedField[]>;
297     before?: (
298     channel: TextBasedChannel,
299     sentMessages: Array<Message | null>
300     ) => Awaitable<void>;
301     previews?: Array<MessageCreateOptions | MessagePayload>;
302     url?: string | null;
303     } = {}
304     ) {
305     if (!this.client.configManager.systemConfig.logging?.enabled) {
306     return;
307     }
308    
309     const { previews = [] } = params;
310     const logChannelId =
311     this.client.configManager.systemConfig.logging?.channels?.echo_send_logs;
312    
313     if (!logChannelId) {
314     return;
315     }
316    
317     try {
318     const channel = await safeChannelFetch(await this.client.getHomeGuild(), logChannelId);
319    
320     if (!channel?.isTextBased()) {
321     return;
322     }
323    
324     const sentMessages = [];
325    
326     for (const preview of previews) {
327     const sentMessage = await EmbedSchemaParser.sendMessage(channel, {
328     ...preview,
329     reply: undefined
330     } as MessageCreateOptions).catch(logError);
331     sentMessages.push(sentMessage ?? null);
332     }
333    
334     await params.before?.(channel, sentMessages);
335    
336     const embedFields = [
337     {
338     name: "Command Name",
339     value: this.name
340     },
341     {
342     name: "Guild Info",
343     value: guildInfo(message.guild!),
344     inline: true
345     },
346     {
347     name: "Channel Info",
348     value: channelInfo(message.channel as Channel),
349     inline: true
350     },
351     {
352     name: "User (Executor)",
353     value: userInfo(message.member!.user as User),
354     inline: true
355     },
356     {
357     name: "Mode",
358     value: message instanceof Message ? "Legacy" : "Application Interaction"
359     },
360     ...(params.url !== null
361     ? [
362     {
363     name: "Message URL",
364     value:
365     params.url ??
366     (message instanceof Message ? message.url : "*Not available*")
367     }
368     ]
369     : [])
370     ];
371    
372     await channel
373     ?.send({
374     embeds: [
375     {
376     author: {
377     name: message.member?.user.username as string,
378     icon_url: (message.member?.user as User)?.displayAvatarURL()
379     },
380     title: "A command was executed",
381     color: 0x007bff,
382     fields: (await params.fields?.(embedFields)) ?? embedFields,
383     ...options
384     }
385     ]
386     })
387     .catch(logError);
388     } catch (error) {
389     logError(error);
390     }
391     }
392    
393     /**
394     * Check for command cooldowns.
395     *
396     * @param message The target message
397     * @returns {Promise<boolean>} Whether to abort the command
398     */
399     private async cooldownCheck(message: CommandMessage): Promise<boolean> {
400     if (!this.cooldown) {
401     return false;
402     }
403    
404     const { cooldown, enabled } = this.client.cooldown.lock(
405     message.guildId!,
406     this.name,
407     this.cooldown
408     );
409    
410     if (enabled) {
411     const seconds = Math.max(Math.ceil(((cooldown ?? 0) - Date.now()) / 1000), 1);
412    
413     await this.deferredReply(message, {
414     embeds: [
415     {
416     description: `${this.emoji(
417     "clock_red"
418     )} You're being rate limited, please wait for **${seconds}** second${
419     seconds === 1 ? "" : "s"
420     }.`,
421     color: 0xf14a60
422     }
423     ],
424     ephemeral: true
425     });
426    
427     return true;
428     }
429    
430     return false;
431     }
432    
433     protected async validateCommandBuiltInPermissions(
434     member: GuildMember,
435     hasOverwrite: boolean
436     ): Promise<boolean> {
437     if (
438     this.client.configManager.systemConfig.default_permissions_mode === "ignore" ||
439     (hasOverwrite &&
440     this.client.configManager.systemConfig.default_permissions_mode === "overwrite")
441     ) {
442     return true;
443     }
444    
445     return (
446     (this.permissionMode === "and" && member.permissions.has(this.permissions, true)) ||
447     (this.permissionMode === "or" && member.permissions.any(this.permissions, true))
448     );
449     }
450    
451     protected async validateNonSystemAdminPermissions({ message }: RunCommandOptions) {
452     const commandName = this.name;
453     const { disabled_commands } = this.client.configManager.systemConfig;
454    
455     if (disabled_commands.includes(commandName ?? "")) {
456     throw new PermissionError("This command is disabled.");
457     }
458    
459     const { channels, guild } =
460     this.client.configManager.config[message.guildId!]?.disabled_commands ?? {};
461    
462     if (guild && guild.includes(commandName ?? "")) {
463     throw new PermissionError("This command is disabled in this server.");
464     }
465    
466     if (
467     channels &&
468     channels[message.channelId!] &&
469     channels[message.channelId!].includes(commandName ?? "")
470     ) {
471     throw new PermissionError("This command is disabled in this channel.");
472     }
473    
474     return true;
475     }
476    
477     protected validateSystemAdminPermissions(member: GuildMember) {
478     const isSystemAdmin = this.client.configManager.systemConfig.system_admins.includes(
479     member.id
480     );
481    
482     if (this.systemAdminOnly && !isSystemAdmin) {
483     throw new PermissionError("This command is only available to system administrators.");
484     }
485    
486     return {
487     isSystemAdmin
488     };
489     }
490    
491     protected async validatePermissions(
492     options: RunCommandOptions
493     ): Promise<{ result?: PermissionValidationResult; abort?: boolean; error?: string }> {
494     const { message } = options;
495    
496     try {
497     const { isSystemAdmin } = this.validateSystemAdminPermissions(
498     message.member as GuildMember
499     );
500    
501     if (isSystemAdmin) {
502     return { result: true };
503     }
504    
505     if (this.client.commandManager.isBanned(message.member!.user.id)) {
506     return { abort: true };
507     }
508    
509     const builtInValidationResult = await this.validateCommandBuiltInPermissions(
510     message.member as GuildMember,
511     !!this.client.commandPermissionOverwriteManager.permissionOverwrites.get(
512     `${message.guildId!}____${this.name}`
513     )?.length
514     );
515    
516     if (!builtInValidationResult) {
517     return { result: false };
518     }
519    
520     const { result: permissionOverwriteResult } =
521     await this.client.commandPermissionOverwriteManager.validatePermissionOverwrites({
522     commandName: this.name,
523     guildId: message.guildId!,
524     member: message.member as GuildMember,
525     channelId: message.channelId!
526     });
527    
528     if (!permissionOverwriteResult) {
529     return {
530     error: "You don't have enough permissions to run this command."
531     };
532     }
533    
534     return {
535     result: await this.validateNonSystemAdminPermissions(options)
536     };
537     } catch (error) {
538     if (error instanceof PermissionError) {
539     return {
540     error: error.message
541     };
542     }
543    
544     logError(error);
545    
546     return {
547     abort: true
548     };
549     }
550     }
551    
552     protected async doChecks(options: RunCommandOptions) {
553     const { message } = options;
554     const {
555     result: permissionValidationResult,
556     abort,
557     error
558     } = await this.validatePermissions(options);
559    
560     if (abort) {
561     return;
562     }
563    
564     const isPermitted =
565     (typeof permissionValidationResult === "boolean" && permissionValidationResult) ||
566     (typeof permissionValidationResult === "object" &&
567     permissionValidationResult.isPermitted);
568    
569     if (!isPermitted || error) {
570     const permissionValidationFailureSource =
571     (typeof permissionValidationResult === "object" &&
572     permissionValidationResult.source) ||
573     undefined;
574     const debugMode =
575     (this.client.configManager.systemConfig.debug_mode ||
576     this.client.configManager.config[message.guildId!]?.debug_mode) ??
577     false;
578    
579     await this.error(
580     message,
581     `${error ?? "You don't have permission to run this command."}${
582     debugMode && permissionValidationFailureSource
583     ? `\nSource: \`${permissionValidationFailureSource}\``
584     : ""
585     }`
586     );
587    
588     return false;
589     }
590    
591     if (this.cooldown) {
592     const abort = await this.cooldownCheck(message);
593    
594     if (abort) {
595     return;
596     }
597     }
598    
599     return true;
600     }
601    
602     protected async parseArguments({ message, context }: RunCommandOptions) {
603     if (!(message instanceof Message) || !context.isLegacy) {
604     return true;
605     }
606    
607     const { error, parsedArgs } = await Command.argumentParser.parse({
608     message,
609     input: message.content,
610     prefix: context.prefix,
611     rules: this.validationRules
612     });
613    
614     if (error) {
615     await this.error(message, error);
616     return false;
617     }
618    
619     if (
620     context.isLegacy &&
621     this.subCommandCheck &&
622     !this.subcommands.includes(context.args[0])
623     ) {
624     await this.error(
625     message,
626     `Please provide a valid subcommand! The valid subcommands are \`${this.subcommands.join(
627     "`, `"
628     )}\`.`
629     );
630    
631     return false;
632     }
633    
634     return parsedArgs;
635     }
636    
637     async run(options: RunCommandOptions) {
638     const { message, context, checkOnly = false, onAbort } = options;
639    
640     this.message = message;
641    
642     if (!(await this.doChecks(options))) {
643     this.message = undefined;
644     onAbort?.();
645     return;
646     }
647    
648     const parsedArgs = await this.parseArguments(options);
649    
650     if (parsedArgs === false) {
651     this.message = undefined;
652     onAbort?.();
653     return;
654     }
655    
656     if (checkOnly) {
657     this.message = undefined;
658     return;
659     }
660    
661     if (typeof parsedArgs === "object" && context.isLegacy) {
662     context.parsedArgs = [];
663    
664     for (const key in parsedArgs) {
665     context.parsedArgs[key as unknown as number] = parsedArgs[key];
666     }
667    
668     context.parsedNamedArgs = parsedArgs;
669     }
670    
671     try {
672     const commandReturn = await this.execute(message, context);
673     this.message = undefined;
674     return commandReturn;
675     } catch (error) {
676     if (error instanceof CommandAbortedError) {
677     if (error.options) {
678     await this.deferredReply(message, error.options);
679     }
680    
681     this.message = undefined;
682     onAbort?.();
683     return;
684     }
685    
686     throw error;
687     }
688     }
689     }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26