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

Contents of /branches/5.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: 28870 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 APIMessage,
22 ApplicationCommandType,
23 CacheType,
24 ChatInputCommandInteraction,
25 ContextMenuCommandBuilder,
26 ContextMenuCommandInteraction,
27 GuildBasedChannel,
28 GuildMember,
29 InteractionDeferReplyOptions,
30 InteractionEditReplyOptions,
31 InteractionReplyOptions,
32 Message,
33 MessageCreateOptions,
34 MessageMentions,
35 MessagePayload,
36 PermissionResolvable,
37 PermissionsBitField,
38 Role,
39 SlashCommandBuilder,
40 Snowflake,
41 User
42 } from "discord.js";
43 import { ChatInputCommandContext, ContextMenuCommandContext, LegacyCommandContext } from "../services/CommandManager";
44 import { stringToTimeInterval } from "../utils/datetime";
45 import { log, logError } from "../utils/logger";
46 import { getEmoji, isSnowflake } from "../utils/utils";
47 import Client from "./Client";
48
49 export type CommandMessage = Message<boolean> | ChatInputCommandInteraction<CacheType> | ContextMenuCommandInteraction;
50 export type BasicCommandContext = LegacyCommandContext | ChatInputCommandContext;
51 export type AnyCommandContext = BasicCommandContext | ContextMenuCommandContext;
52 export type CommandReturn =
53 | ((MessageCreateOptions | APIMessage | InteractionReplyOptions) & { __reply?: boolean })
54 | undefined
55 | null
56 | void;
57
58 export enum ArgumentType {
59 String = 1,
60 StringRest,
61 Number,
62 Integer,
63 Float,
64 Boolean,
65 Snowflake,
66 User,
67 GuildMember,
68 Channel,
69 Role,
70 Link,
71 TimeInterval
72 }
73
74 export interface ValidationRule {
75 types?: readonly ArgumentType[];
76 optional?: boolean;
77 default?: any;
78 requiredErrorMessage?: string;
79 typeErrorMessage?: string;
80 entityNotNullErrorMessage?: string;
81 entityNotNull?: boolean;
82 minValue?: number;
83 maxValue?: number;
84 minMaxErrorMessage?: string;
85 lengthMaxErrorMessage?: string;
86 lengthMax?: number;
87 name?: string;
88 timeMilliseconds?: boolean;
89 }
90
91 type ValidationRuleAndOutputMap = {
92 [ArgumentType.Boolean]: boolean;
93 [ArgumentType.Channel]: GuildBasedChannel;
94 [ArgumentType.Float]: number;
95 [ArgumentType.Number]: number;
96 [ArgumentType.Integer]: number;
97 [ArgumentType.TimeInterval]: number;
98 [ArgumentType.Link]: string;
99 [ArgumentType.Role]: Role;
100 [ArgumentType.Snowflake]: Snowflake;
101 [ArgumentType.String]: string;
102 [ArgumentType.StringRest]: string;
103 [ArgumentType.User]: User;
104 [ArgumentType.GuildMember]: GuildMember;
105 };
106
107 type ValidationRuleParsedArg<T extends ValidationRule["types"]> = T extends ReadonlyArray<infer U>
108 ? U extends keyof ValidationRuleAndOutputMap
109 ? ValidationRuleAndOutputMap[U]
110 : never
111 : T extends Array<infer U>
112 ? U extends keyof ValidationRuleAndOutputMap
113 ? ValidationRuleAndOutputMap[U]
114 : never
115 : never;
116
117 export type ValidationRuleParsedArgs<T extends readonly ValidationRule[]> = {
118 [K in keyof T]: ValidationRuleParsedArg<T[K]["types"]>;
119 };
120
121 // TODO: Split the logic into separate methods
122
123 export default abstract class Command {
124 public readonly name: string = "";
125 public group: string = "Default";
126 public readonly aliases: string[] = [];
127
128 public readonly supportsInteractions: boolean = true;
129 public readonly supportsLegacy: boolean = true;
130
131 public readonly permissions: PermissionResolvable[] = [];
132 public readonly validationRules: readonly ValidationRule[] = [];
133 public readonly permissionMode: "or" | "and" = "and";
134 public readonly systemAdminOnly: boolean = false;
135
136 public readonly description?: string;
137 public readonly detailedDescription?: string;
138 public readonly argumentSyntaxes?: string[];
139 public readonly availableOptions?: Record<string, string>;
140 public readonly beta: boolean = false;
141 public readonly since: string = "1.0.0";
142 public readonly botRequiredPermissions: PermissionResolvable[] = [];
143 public readonly slashCommandBuilder?:
144 | Partial<Pick<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">>
145 | Omit<SlashCommandBuilder, "addSubcommand" | "addSubcommandGroup">;
146
147 public readonly applicationCommandType: ApplicationCommandType = ApplicationCommandType.ChatInput;
148 public readonly otherApplicationCommandBuilders: (ContextMenuCommandBuilder | SlashCommandBuilder)[] = [];
149
150 public readonly subcommands: string[] = [];
151
152 constructor(protected client: Client) {}
153
154 abstract execute(message: CommandMessage, context: AnyCommandContext): Promise<CommandReturn>;
155
156 async deferIfInteraction(message: CommandMessage, options?: InteractionDeferReplyOptions) {
157 if (message instanceof ChatInputCommandInteraction) return await message.deferReply(options).catch(logError);
158 }
159
160 async deferredReply(
161 message: CommandMessage,
162 options: MessageCreateOptions | MessagePayload | InteractionEditReplyOptions | string
163 ) {
164 if (message instanceof ChatInputCommandInteraction || message instanceof ContextMenuCommandInteraction) {
165 return message.deferred ? await message.editReply(options) : await message.reply(options as any);
166 }
167
168 return message.reply(options as any);
169 }
170
171 async error(message: CommandMessage, errorMessage?: string) {
172 return await this.deferredReply(
173 message,
174 errorMessage
175 ? `${this.emoji("error")} ${errorMessage}`
176 : `⚠️ An error has occurred while performing this action. Please make sure that the bot has the required permissions to perform this action.`
177 );
178 }
179
180 async success(message: CommandMessage, successMessage?: string) {
181 return await this.deferredReply(
182 message,
183 successMessage ? `${this.emoji("check")} ${successMessage}` : `Successfully completed the given task.`
184 );
185 }
186
187 emoji(name: string) {
188 return getEmoji(this.client, name);
189 }
190
191 async run(message: CommandMessage, context: AnyCommandContext, checkOnly = false) {
192 const isSystemAdmin = this.client.configManager.systemConfig.system_admins.includes(message.member!.user.id);
193
194 if (this.systemAdminOnly && !isSystemAdmin) {
195 message
196 .reply({
197 content: `${this.emoji("error")} You don't have permission to run this command.`,
198 ephemeral: true
199 })
200 .catch(logError);
201
202 return;
203 }
204
205 if (!isSystemAdmin) {
206 const commandName = this.client.commands.get(context.isLegacy ? context.argv[0] : context.commandName)?.name;
207 const { disabled_commands } = this.client.configManager.systemConfig;
208
209 if (disabled_commands.includes(commandName ?? "")) {
210 await this.error(message, "This command is disabled.");
211 return;
212 }
213
214 const { channels, guild } = this.client.configManager.config[message.guildId!]?.disabled_commands ?? {};
215
216 if (guild && guild.includes(commandName ?? "")) {
217 await this.error(message, "This command is disabled in this server.");
218 return;
219 }
220
221 if (channels && channels[message.channelId!] && channels[message.channelId!].includes(commandName ?? "")) {
222 await this.error(message, "This command is disabled in this channel.");
223 return;
224 }
225 }
226
227 const { validationRules, permissions } = this;
228 const parsedArgs = [];
229 const parsedNamedArgs: Record<string, any> = {};
230
231 if (!isSystemAdmin) {
232 let member: GuildMember = <any>message.member!;
233
234 if (!(member.permissions as any)?.has) {
235 try {
236 member = await message.guild!.members.fetch(member.user.id);
237
238 if (!member) {
239 throw new Error("Invalid member");
240 }
241 } catch (e) {
242 logError(e);
243 message
244 .reply({
245 content: `Sorry, I couldn't determine whether you have the enough permissions to perform this action or not. Please contact the bot developer.`,
246 ephemeral: true
247 })
248 .catch(logError);
249 return;
250 }
251 }
252
253 const mode = this.client.configManager.config[message.guildId!]?.permissions?.mode;
254
255 if (permissions.length > 0) {
256 const memberBotPermissions = this.client.permissionManager.getMemberPermissions(member);
257 const memberRequiredPermissions = new PermissionsBitField(permissions).toArray();
258
259 if (this.permissionMode === "and") {
260 for (const permission of permissions) {
261 if (!member.permissions.has(permission, true)) {
262 const mode = this.client.configManager.config[message.guildId!]?.permissions?.mode;
263
264 if (mode !== "advanced" && mode !== "levels") {
265 await message.reply({
266 content: `${this.emoji("error")} You don't have permission to run this command.`,
267 ephemeral: true
268 });
269
270 log("Skip");
271
272 return;
273 }
274
275 const memberBotPermissions = this.client.permissionManager.getMemberPermissions(member);
276 const memberRequiredPermissions = new PermissionsBitField(permissions).toArray();
277
278 log("PERMS: ", [...memberBotPermissions.values()]);
279 log("PERMS 2: ", memberRequiredPermissions);
280
281 for (const memberRequiredPermission of memberRequiredPermissions) {
282 if (!memberBotPermissions.has(memberRequiredPermission)) {
283 await message.reply({
284 content: `${this.emoji("error")} You don't have permission to run this command.`,
285 ephemeral: true
286 });
287
288 return;
289 }
290 }
291 }
292 }
293 } else
294 orMode: {
295 for (const permission of permissions) {
296 if (member.permissions.has(permission, true)) {
297 break orMode;
298 }
299 }
300
301 if (mode === "advanced" || mode === "levels") {
302 for (const memberRequiredPermission of memberRequiredPermissions) {
303 if (memberBotPermissions.has(memberRequiredPermission)) {
304 break orMode;
305 }
306 }
307 }
308
309 await message.reply({
310 content: `${this.emoji("error")} You don't have enough permissions to run this command.`,
311 ephemeral: true
312 });
313
314 return;
315 }
316 }
317
318 const permissionOverwrite = this.client.commandManager.permissionOverwrites.get(
319 `${message.guildId!}____${this.name}`
320 );
321
322 log([...this.client.commandManager.permissionOverwrites.keys()]);
323
324 errorRootBlock: if (permissionOverwrite) {
325 let userCheckPassed = false;
326 let levelCheckPassed = false;
327 let roleCheckPassed = false;
328 let permissonCheckPassed = false;
329
330 permissionOverwriteIfBlock: {
331 if (permissionOverwrite.requiredUsers.length > 0) {
332 userCheckPassed = permissionOverwrite.requiredUsers.includes(member.user.id);
333
334 if (permissionOverwrite.mode === "AND" && !userCheckPassed) {
335 break permissionOverwriteIfBlock;
336 }
337
338 if (permissionOverwrite.mode === "OR" && userCheckPassed) {
339 log("User check passed [OR]");
340 break errorRootBlock;
341 }
342 }
343
344 if (mode === "levels" && permissionOverwrite.requiredLevel !== null) {
345 const level = this.client.permissionManager.getMemberPermissionLevel(member);
346 levelCheckPassed = level >= permissionOverwrite.requiredLevel;
347
348 log("level", level, "<", permissionOverwrite.requiredLevel);
349
350 if (!levelCheckPassed && permissionOverwrite.mode === "AND") {
351 break permissionOverwriteIfBlock;
352 } else if (levelCheckPassed && permissionOverwrite.mode === "OR") {
353 log("Level check passed [OR]");
354 break errorRootBlock;
355 }
356 }
357
358 if (permissionOverwrite.requiredPermissions.length > 0) {
359 const requiredPermissions = permissionOverwrite.requiredPermissions as PermissionResolvable[];
360
361 if (permissionOverwrite.requiredPermissionMode === "OR") {
362 let found = false;
363
364 for (const permission of requiredPermissions) {
365 if (member.permissions.has(permission, true)) {
366 found = true;
367 break;
368 }
369 }
370
371 permissonCheckPassed = found;
372
373 if (!found && permissionOverwrite.mode === "AND") {
374 break permissionOverwriteIfBlock;
375 } else if (found && permissionOverwrite.mode === "OR") {
376 log("Permission check passed [OR_OR]");
377 break errorRootBlock;
378 }
379 } else {
380 log(requiredPermissions);
381 permissonCheckPassed = member.permissions.has(requiredPermissions, true);
382
383 if (!permissonCheckPassed && permissionOverwrite.mode === "AND") {
384 log("Fail");
385 break permissionOverwriteIfBlock;
386 } else if (permissonCheckPassed && permissionOverwrite.mode === "OR") {
387 log("Permission check passed [AND_OR]");
388 break errorRootBlock;
389 }
390 }
391 }
392
393 if (permissionOverwrite.requiredRoles.length > 0) {
394 roleCheckPassed = member.roles.cache.hasAll(...permissionOverwrite.requiredRoles);
395
396 if (!roleCheckPassed && permissionOverwrite.mode === "AND") {
397 break permissionOverwriteIfBlock;
398 } else if (roleCheckPassed && permissionOverwrite.mode === "OR") {
399 break errorRootBlock;
400 }
401 }
402
403 log("userCheckPassed", userCheckPassed);
404 log("levelCheckPassed", levelCheckPassed);
405 log("permissonCheckPassed", permissonCheckPassed);
406 log("roleCheckPassed", roleCheckPassed);
407
408 if (permissionOverwrite.mode === "OR") {
409 break permissionOverwriteIfBlock;
410 }
411
412 if (
413 permissionOverwrite.requiredChannels.length > 0 &&
414 !permissionOverwrite.requiredChannels.includes(message.channelId!)
415 ) {
416 await message.reply({
417 content: `${this.emoji("error")} This command is disabled in this channel.`,
418 ephemeral: true
419 });
420
421 return;
422 }
423
424 break errorRootBlock;
425 }
426
427 await message.reply({
428 content: `${this.emoji("error")} You don't have enough permissions to run this command.`,
429 ephemeral: true
430 });
431
432 return;
433 }
434 }
435
436 if (context.isLegacy) {
437 let index = 0;
438
439 loop: for await (const rule of validationRules) {
440 const arg = context.args[index];
441
442 if (arg === undefined) {
443 if (!rule.optional) {
444 await this.error(message, rule.requiredErrorMessage ?? `Argument #${index} is required`);
445 return;
446 }
447
448 if (rule.default !== undefined) {
449 parsedArgs.push(rule.default);
450 }
451
452 continue;
453 }
454
455 if (rule.types) {
456 const prevLengthOuter = parsedArgs.length;
457
458 for (const type of rule.types) {
459 const prevLength = parsedArgs.length;
460
461 if (
462 /^(\-)?[\d\.]+$/.test(arg) &&
463 (((rule.minValue || rule.maxValue) && type === ArgumentType.Float) ||
464 type === ArgumentType.Integer ||
465 type === ArgumentType.Number)
466 ) {
467 const float = parseFloat(arg);
468
469 if (
470 !isNaN(float) &&
471 ((rule.minValue !== undefined && rule.minValue > float) ||
472 (rule.maxValue !== undefined && rule.maxValue < float))
473 ) {
474 await message.reply(
475 rule.minMaxErrorMessage ??
476 `Argument #${index} has a min/max numeric value range but the given value is out of range.`
477 );
478 return;
479 }
480 }
481
482 switch (type) {
483 case ArgumentType.Boolean:
484 if (["true", "false"].includes(arg.toLowerCase())) {
485 parsedArgs[index] = arg.toLowerCase() === "true";
486 }
487
488 break;
489
490 case ArgumentType.Float:
491 const float = parseFloat(arg);
492
493 if (isNaN(float)) {
494 break;
495 }
496
497 parsedArgs[index] = float;
498 break;
499
500 case ArgumentType.Integer:
501 if (!/^(\-)?\d+$/.test(arg)) {
502 break;
503 }
504
505 const int = parseInt(arg);
506
507 if (isNaN(int)) {
508 break;
509 }
510
511 parsedArgs[index] = int;
512 break;
513
514 case ArgumentType.Number:
515 const number = arg.includes(".") ? parseFloat(arg) : parseInt(arg);
516
517 if (isNaN(number)) {
518 break;
519 }
520
521 parsedArgs[index] = number;
522 break;
523
524 case ArgumentType.TimeInterval:
525 const { result, error } = stringToTimeInterval(arg, {
526 milliseconds: rule.timeMilliseconds ?? false
527 });
528
529 if (error) {
530 if (rule.types.length === 1) {
531 await message
532 .reply({
533 ephemeral: true,
534 content: `${this.emoji("error")} ${error}`
535 })
536 .catch(logError);
537
538 return;
539 }
540
541 break;
542 }
543
544 if (
545 !isNaN(result) &&
546 ((rule.minValue !== undefined && rule.minValue > result) ||
547 (rule.maxValue !== undefined && rule.maxValue < result))
548 ) {
549 await message.reply(
550 `${this.emoji("error")} ` + rule.minMaxErrorMessage ??
551 `Argument #${index} has a min/max numeric time value range but the given value is out of range.`
552 );
553 return;
554 }
555
556 parsedArgs[index] = result;
557 break;
558
559 case ArgumentType.Link:
560 try {
561 parsedArgs[index] = new URL(arg);
562 } catch (e) {
563 break;
564 }
565
566 break;
567
568 case ArgumentType.String:
569 if (arg.trim() === "") break;
570
571 parsedArgs[index] = arg;
572 break;
573
574 case ArgumentType.Snowflake:
575 if (!isSnowflake(arg)) break;
576
577 parsedArgs[index] = arg;
578 break;
579
580 case ArgumentType.User:
581 case ArgumentType.GuildMember:
582 case ArgumentType.Channel:
583 case ArgumentType.Role:
584 // TODO: Use message.mentions object to improve performance and reduce API requests
585
586 let id;
587
588 if (MessageMentions.UsersPattern.test(arg)) {
589 id = arg.substring(arg.includes("!") ? 3 : 2, arg.length - 1);
590 } else if (MessageMentions.ChannelsPattern.test(arg)) {
591 id = arg.substring(2, arg.length - 1);
592 } else if (MessageMentions.RolesPattern.test(arg)) {
593 id = arg.substring(3, arg.length - 1);
594 } else if (isSnowflake(arg)) {
595 id = arg;
596 } else {
597 break;
598 }
599
600 try {
601 let entity = null;
602
603 if (type === ArgumentType.User) entity = await this.client.users.fetch(id);
604 else {
605 entity =
606 type === ArgumentType.Role
607 ? await message.guild!.roles.fetch(id)
608 : type === ArgumentType.Channel
609 ? await message.guild!.channels.fetch(id)
610 : await message.guild!.members.fetch(id);
611 }
612
613 if (!entity) {
614 throw new Error("Invalid entity received");
615 }
616
617 parsedArgs[index] = entity;
618 } catch (e) {
619 logError(e);
620
621 if (rule.entityNotNull) {
622 await message.reply(
623 `${this.emoji("error")} ` + rule.entityNotNullErrorMessage ??
624 `Argument ${index} is invalid`
625 );
626 return;
627 }
628
629 parsedArgs[index] = null;
630 }
631
632 break;
633
634 case ArgumentType.StringRest:
635 if (arg.trim() === "") break;
636
637 let str = ((message as Message).content ?? "")
638 .slice(context.prefix.length)
639 .trimStart()
640 .slice(context.argv[0].length)
641 .trimStart();
642
643 for (let i = 0; i < index; i++) {
644 str = str.slice(context.args[i].length).trimStart();
645 }
646
647 str = str.trimEnd();
648
649 if (str === "") break;
650
651 parsedArgs[index] = str;
652
653 if (rule.name) {
654 parsedNamedArgs[rule.name] = parsedArgs[index];
655 }
656
657 break loop;
658 }
659
660 if (
661 rule.lengthMax !== undefined &&
662 typeof parsedArgs[index] === "string" &&
663 parsedArgs[index].length > rule.lengthMax
664 ) {
665 await message.reply(
666 `${this.emoji("error")} ` + rule.lengthMaxErrorMessage ?? `Argument #${index} is too long`
667 );
668 return;
669 }
670
671 if (prevLength !== parsedArgs.length) {
672 break;
673 }
674 }
675
676 if (prevLengthOuter === parsedArgs.length) {
677 await message.reply(
678 `${this.emoji("error")} ` + rule.typeErrorMessage ?? `Argument #${index} is invalid, type mismatch`
679 );
680 return;
681 }
682 }
683
684 if (rule.name) {
685 parsedNamedArgs[rule.name] = parsedArgs[index];
686 }
687
688 index++;
689 }
690 }
691
692 if (!checkOnly) {
693 return await this.execute(message, {
694 ...context,
695 ...(context.isLegacy
696 ? {
697 parsedArgs,
698 parsedNamedArgs
699 }
700 : {})
701 });
702 }
703 }
704 }

[email protected]
ViewVC Help
Powered by ViewVC 1.1.26