Skip to content

Commit aba1e36

Browse files
feat: queue add command
1 parent 659821a commit aba1e36

File tree

7 files changed

+341
-4
lines changed

7 files changed

+341
-4
lines changed

.vscode/settings.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@
3030
"Buildable",
3131
"commitlint",
3232
"cooldown",
33+
"crosspostable",
3334
"datetime",
3435
"deepmerge",
3536
"dmhistory",

TODO.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,10 +12,10 @@
1212
- NSFW.js AI model integration
1313
- Reply and thread based AI chat conversation support. Storing messages in database, disk (JSON) or caching the last 100 messages in memory to send them to the AI model. Reply context will be limited to the reply and thread context will be limited to thread.
1414
- Log infraction ~~creation~~/edition/deletion when using `-infraction` commands
15-
- `-modmsg` command
1615
- Better cache management (especially for permission managers and command overwrites)
1716

1817
## 9.x
1918

2019
- Make a semaphore that blocks when a specific condition is met
2120
- Listen to raw message delete event and raw message edit event for logging
21+
- Template string function for parsing emoji literals and fetching them

src/framework/typescript/arguments/DurationArgument.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,12 @@ import Argument from "./Argument";
2424
import { ErrorType } from "./InvalidArgumentError";
2525

2626
class DurationArgument extends Argument<Duration> {
27+
public static readonly defaultErrors = {
28+
[ErrorType.Required]: "You must specify a duration/time to perform this action!",
29+
[ErrorType.InvalidType]: "You must specify a valid duration/time to perform this action.",
30+
[ErrorType.InvalidRange]: "The given duration/time is out of range."
31+
};
32+
2733
public override toString(): string {
2834
return this.stringValue!.toString();
2935
}

src/main/typescript/commands/automation/AFKsCommand.ts

Lines changed: 15 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,19 +12,31 @@ import {
1212
ChatInputCommandInteraction,
1313
Colors,
1414
ComponentType,
15+
PermissionFlagsBits,
1516
escapeMarkdown,
16-
italic,
17-
PermissionFlagsBits
17+
italic
1818
} from "discord.js";
1919

2020
// TODO: Pagination
2121
class AFKsCommand extends Command {
2222
public override readonly name = "afks";
2323
public override readonly description: string = "Manage others' AFK statuses.";
24-
public override readonly usage = ["[...reason: RestString]"];
24+
public override readonly usage = ["<subcommand: String> [...args: Any[]]"];
2525
public override readonly aliases = ["manageafks"];
2626
public override readonly subcommands = ["list", "remove", "clear"];
2727
public override readonly permissions = [PermissionFlagsBits.ManageMessages];
28+
public override readonly subcommandMeta = {
29+
list: {
30+
description: "List all users with AFK statuses."
31+
},
32+
remove: {
33+
description: "Remove a user's AFK status.",
34+
usage: ["<user: User>", "[...reason: RestString]"]
35+
},
36+
clear: {
37+
description: "Clear all AFK statuses in this server."
38+
}
39+
};
2840

2941
@Inject()
3042
private readonly afkService!: AFKService;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { TakesArgument } from "@framework/arguments/ArgumentTypes";
2+
import DurationArgument from "@framework/arguments/DurationArgument";
3+
import { ErrorType } from "@framework/arguments/InvalidArgumentError";
4+
import RestStringArgument from "@framework/arguments/RestStringArgument";
5+
import { Command } from "@framework/commands/Command";
6+
import type Context from "@framework/commands/Context";
7+
import { Inject } from "@framework/container/Inject";
8+
import Duration from "@framework/datetime/Duration";
9+
import CommandExecutionQueue from "@main/queues/CommandExecutionQueue";
10+
import CommandManager from "@main/services/CommandManager";
11+
import QueueService from "@main/services/QueueService";
12+
import { Message, PermissionFlagsBits, inlineCode } from "discord.js";
13+
14+
type QueueAddCommandArgs = {
15+
runAfter: Duration;
16+
command: string;
17+
};
18+
19+
@TakesArgument<QueueAddCommandArgs>({
20+
names: ["runAfter"],
21+
types: [DurationArgument],
22+
optional: false,
23+
errorMessages: [DurationArgument.defaultErrors],
24+
interactionName: "run_after"
25+
})
26+
@TakesArgument<QueueAddCommandArgs>({
27+
names: ["command"],
28+
types: [RestStringArgument],
29+
optional: false,
30+
errorMessages: [
31+
{
32+
[ErrorType.Required]: "You must specify a command to queue."
33+
}
34+
],
35+
interactionName: "command"
36+
})
37+
class QueueAddCommand extends Command {
38+
public override readonly name = "queue::add";
39+
public override readonly description: string = "Add a command execution queued job.";
40+
public override readonly detailedDescription: string =
41+
"Queues the given command to be run at a later time.";
42+
public override readonly defer = true;
43+
public override readonly permissions = [PermissionFlagsBits.ManageGuild];
44+
45+
@Inject()
46+
private readonly commandManager!: CommandManager;
47+
48+
@Inject()
49+
private readonly queueManager!: QueueService;
50+
51+
public override async execute(context: Context, args: QueueAddCommandArgs): Promise<void> {
52+
const { command, runAfter } = args;
53+
const commandName = command.slice(0, Math.max(command.indexOf(" "), command.indexOf("\n")));
54+
55+
if (!this.commandManager.commands.has(commandName)) {
56+
return void context.error(
57+
`The specified command ${inlineCode(commandName)} does not exist.`
58+
);
59+
}
60+
61+
let messageId: string;
62+
let reply: Message | undefined;
63+
64+
if (context.isLegacy()) {
65+
messageId = context.commandMessage.id;
66+
} else {
67+
reply = await context.reply(`${context.emoji("loading")} Creating queue job...`);
68+
messageId = reply.id;
69+
}
70+
71+
const id = await this.queueManager
72+
.create(CommandExecutionQueue, {
73+
data: {
74+
guildId: context.guildId,
75+
memberId: context.memberId!,
76+
messageId,
77+
channelId: context.channelId,
78+
commandString: command,
79+
fromInteraction: context.isChatInput()
80+
},
81+
guildId: context.guildId,
82+
runsAt: new Date(Date.now() + runAfter.toMilliseconds())
83+
})
84+
.schedule();
85+
86+
const response = `${context.emoji("check")} Successfully queued the given command. The queue ID is ${inlineCode(id.toString())}.`;
87+
88+
if (reply) {
89+
await reply.edit(response);
90+
} else {
91+
await context.reply(response);
92+
}
93+
}
94+
}
95+
96+
export default QueueAddCommand;
Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import type { Buildable } from "@framework/commands/Command";
2+
import { Command } from "@framework/commands/Command";
3+
import { PermissionFlagsBits } from "discord.js";
4+
5+
class QueueCommand extends Command {
6+
public override readonly name = "queue";
7+
public override readonly description: string = "Manage queued jobs.";
8+
public override readonly detailedDescription: string = "Custom command.";
9+
public override readonly defer = true;
10+
public override readonly usage = ["<subcommand: String> [...args: Any[]]"];
11+
public override readonly systemPermissions = [];
12+
public override readonly subcommands = ["list", "cancel", "show", "add"];
13+
public override readonly isolatedSubcommands = true;
14+
public override readonly aliases = ["queues", "q"];
15+
public override readonly permissions = [PermissionFlagsBits.ManageGuild];
16+
public override readonly subcommandMeta = {
17+
list: {
18+
description: "List all queued jobs."
19+
},
20+
cancel: {
21+
description: "Cancel a queued job.",
22+
usage: ["<jobId: String>"]
23+
},
24+
show: {
25+
description: "Show a queued job.",
26+
usage: ["<jobId: String>"]
27+
},
28+
add: {
29+
description: "Add a command execution job to the queue.",
30+
usage: ["<runAfter: Duration> <...command: RestString[]>"]
31+
}
32+
};
33+
34+
public override build(): Buildable[] {
35+
return [
36+
this.buildChatInput()
37+
.addSubcommand(subcommand =>
38+
subcommand.setName("list").setDescription("List all queued jobs.")
39+
)
40+
.addSubcommand(subcommand =>
41+
subcommand
42+
.setName("cancel")
43+
.setDescription("Cancel a queued job.")
44+
.addStringOption(option =>
45+
option
46+
.setName("id")
47+
.setDescription("The ID of the job to cancel.")
48+
.setRequired(true)
49+
)
50+
)
51+
.addSubcommand(subcommand =>
52+
subcommand
53+
.setName("show")
54+
.setDescription("Show a queued job.")
55+
.addStringOption(option =>
56+
option
57+
.setName("id")
58+
.setDescription("The ID of the job to show.")
59+
.setRequired(true)
60+
)
61+
)
62+
.addSubcommand(subcommand =>
63+
subcommand
64+
.setName("add")
65+
.setDescription("Add a command execution job to the queue.")
66+
.addStringOption(option =>
67+
option
68+
.setName("run_after")
69+
.setDescription("The duration to wait before running the command.")
70+
.setRequired(true)
71+
)
72+
.addStringOption(option =>
73+
option
74+
.setName("command")
75+
.setDescription("The command to run.")
76+
.setRequired(true)
77+
)
78+
)
79+
];
80+
}
81+
82+
public override async execute(): Promise<void> {}
83+
}
84+
85+
export default QueueCommand;
Lines changed: 137 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,137 @@
1+
import Queue from "@framework/queues/Queue";
2+
import { fetchChannel, fetchMember } from "@framework/utils/entities";
3+
import type {
4+
MessageCreateOptions,
5+
MessagePayload,
6+
MessageReplyOptions,
7+
Snowflake
8+
} from "discord.js";
9+
10+
type CommandExecutionQueuePayload = {
11+
guildId: Snowflake;
12+
memberId: Snowflake;
13+
messageId: Snowflake;
14+
channelId: Snowflake;
15+
commandString: string;
16+
fromInteraction: boolean;
17+
};
18+
19+
class CommandExecutionQueue extends Queue<CommandExecutionQueuePayload> {
20+
public static override readonly uniqueName = "command_execution";
21+
22+
private cloneObject(obj: object) {
23+
const clonedObj = Object.create(Object.getPrototypeOf(obj));
24+
25+
for (const key of Reflect.ownKeys(obj)) {
26+
const descriptor = Object.getOwnPropertyDescriptor(obj, key);
27+
28+
if (descriptor) {
29+
Object.defineProperty(clonedObj, key, descriptor);
30+
}
31+
}
32+
33+
return clonedObj;
34+
}
35+
36+
public async execute({
37+
guildId,
38+
memberId,
39+
channelId,
40+
commandString,
41+
messageId,
42+
fromInteraction
43+
}: CommandExecutionQueuePayload) {
44+
const prefix = this.application.getServiceByName("configManager").config[guildId]?.prefix;
45+
46+
if (!prefix) {
47+
this.application.logger.error(`No prefix found for guild ${guildId}`);
48+
return;
49+
}
50+
51+
const guild = this.application.client.guilds.cache.get(guildId);
52+
53+
if (!guild) {
54+
return;
55+
}
56+
57+
const channel = await fetchChannel(guild, channelId);
58+
59+
if (!channel?.isTextBased()) {
60+
return;
61+
}
62+
63+
const message = await channel.messages.fetch(messageId);
64+
65+
if (!message) {
66+
return;
67+
}
68+
69+
const copy = this.cloneObject(message);
70+
71+
copy!.reply = (...args: [MessagePayload | MessageReplyOptions | string]) =>
72+
message.reply(...(args as [MessageCreateOptions | string]));
73+
copy!.delete = () => Promise.resolve(message!);
74+
copy!.react = () =>
75+
Promise.resolve(null as unknown as ReturnType<NonNullable<typeof message>["react"]>);
76+
77+
copy.content = `${prefix}${commandString}`;
78+
copy.channelId = channel.id;
79+
80+
Object.defineProperty(copy, "channel", {
81+
get: () => channel
82+
});
83+
84+
Object.defineProperty(copy, "url", {
85+
get: () => `https://discord.com/channels/${message.guild.id}/${channel.id}/${messageId}`
86+
});
87+
88+
Object.defineProperty(copy, "guild", {
89+
get: () => guild
90+
});
91+
92+
Object.defineProperty(copy, "mentions", {
93+
value: message.mentions,
94+
enumerable: true,
95+
configurable: true,
96+
writable: false
97+
});
98+
99+
if (!fromInteraction && copy.author.id !== memberId) {
100+
this.application.logger.error(
101+
`Invalid message author for message ${message.id} in guild ${guild.id}`
102+
);
103+
104+
return;
105+
}
106+
107+
const member = await fetchMember(guild, memberId);
108+
109+
if (!member) {
110+
this.application.logger.error(
111+
`Invalid member for message ${message.id} in guild ${guild.id}`
112+
);
113+
114+
return;
115+
}
116+
117+
copy.author = member.user;
118+
119+
Object.defineProperty(copy, "member", {
120+
value: member,
121+
enumerable: true,
122+
configurable: true
123+
});
124+
125+
if (
126+
(await this.application
127+
.getServiceByName("commandManager")
128+
.runCommandFromMessage(copy)) === false
129+
) {
130+
this.application.logger.error(
131+
`Failed to run command from message ${message.id} in guild ${guild.id}`
132+
);
133+
}
134+
}
135+
}
136+
137+
export default CommandExecutionQueue;

0 commit comments

Comments
 (0)