cordsmith 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (47) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +43 -0
  3. package/package.json +72 -0
  4. package/src/handler/@types/command.ts +79 -0
  5. package/src/handler/@types/contextMenu.ts +80 -0
  6. package/src/handler/@types/event.ts +37 -0
  7. package/src/handler/@types/precondition.ts +28 -0
  8. package/src/handler/@types/task.ts +69 -0
  9. package/src/handler/command/CommandHandler.ts +192 -0
  10. package/src/handler/command/functions/UserError.ts +13 -0
  11. package/src/handler/command/functions/attachInteractionListener.ts +271 -0
  12. package/src/handler/command/functions/commandCache.ts +97 -0
  13. package/src/handler/command/functions/cooldowns.ts +93 -0
  14. package/src/handler/command/functions/customId.ts +78 -0
  15. package/src/handler/command/functions/loadCommands.ts +62 -0
  16. package/src/handler/command/functions/owners.ts +10 -0
  17. package/src/handler/command/functions/preconditions/BotPermissions.ts +34 -0
  18. package/src/handler/command/functions/preconditions/Cooldown.ts +37 -0
  19. package/src/handler/command/functions/preconditions/GuildOnly.ts +18 -0
  20. package/src/handler/command/functions/preconditions/OwnerOnly.ts +18 -0
  21. package/src/handler/command/functions/preconditions/UserPermissions.ts +33 -0
  22. package/src/handler/command/functions/registerCommands.ts +140 -0
  23. package/src/handler/command/functions/runPreconditions.ts +103 -0
  24. package/src/handler/command/index.ts +20 -0
  25. package/src/handler/context/ContextMenuHandler.ts +172 -0
  26. package/src/handler/context/functions/attachContextMenuListener.ts +174 -0
  27. package/src/handler/context/functions/loadContextMenus.ts +59 -0
  28. package/src/handler/context/functions/runContextMenuPreconditions.ts +114 -0
  29. package/src/handler/context/index.ts +12 -0
  30. package/src/handler/events/EventHandler.ts +45 -0
  31. package/src/handler/events/functions/attachEvents.ts +95 -0
  32. package/src/handler/events/functions/loadEvents.ts +93 -0
  33. package/src/handler/events/index.ts +2 -0
  34. package/src/handler/manager/HandlerManager.ts +225 -0
  35. package/src/handler/manager/index.ts +2 -0
  36. package/src/handler/manager/registrationPlans.ts +58 -0
  37. package/src/handler/tasks/TaskHandler.ts +73 -0
  38. package/src/handler/tasks/functions/loadTasks.ts +75 -0
  39. package/src/handler/tasks/functions/parseCron.ts +187 -0
  40. package/src/handler/tasks/functions/scheduleTask.ts +106 -0
  41. package/src/handler/tasks/index.ts +4 -0
  42. package/src/handler/utils/env.ts +7 -0
  43. package/src/handler/utils/files.ts +74 -0
  44. package/src/index.ts +39 -0
  45. package/src/structure/Client.ts +8 -0
  46. package/src/utils/index.ts +1 -0
  47. package/src/utils/logger.ts +63 -0
@@ -0,0 +1,18 @@
1
+ import type { Precondition } from "../../../@types/precondition";
2
+
3
+ export const OwnerOnly: Precondition = {
4
+ name: "OwnerOnly",
5
+ run({ meta, ownerIds, interaction }) {
6
+ if (!meta.ownerOnly) return { ok: true };
7
+
8
+ if (!ownerIds.has(interaction.user.id)) {
9
+ return {
10
+ ok: false,
11
+ message: "You can't use this command.",
12
+ ephemeral: true,
13
+ };
14
+ }
15
+
16
+ return { ok: true };
17
+ },
18
+ };
@@ -0,0 +1,33 @@
1
+ import type { PermissionsString } from "discord.js";
2
+ import type { Precondition } from "../../../@types/precondition";
3
+
4
+ function missingPerms(
5
+ has: ReadonlySet<string>,
6
+ required: PermissionsString[],
7
+ ): PermissionsString[] {
8
+ return required.filter((p) => !has.has(p));
9
+ }
10
+
11
+ export const UserPermissions: Precondition = {
12
+ name: "UserPermissions",
13
+ run({ meta, interaction }) {
14
+ if (!meta.userPermissions?.length) return { ok: true };
15
+ if (!interaction.inGuild()) return { ok: true };
16
+
17
+ const memberPerms = interaction.memberPermissions;
18
+ const missing = missingPerms(
19
+ new Set(memberPerms?.toArray() ?? []),
20
+ meta.userPermissions,
21
+ );
22
+
23
+ if (missing.length > 0) {
24
+ return {
25
+ ok: false,
26
+ message: `You're missing permissions: ${missing.join(", ")}`,
27
+ ephemeral: true,
28
+ };
29
+ }
30
+
31
+ return { ok: true };
32
+ },
33
+ };
@@ -0,0 +1,140 @@
1
+ import { REST, Routes } from "discord.js";
2
+ import { logger } from "../../../utils/logger";
3
+ import {
4
+ hashCommandJson,
5
+ type RegisterScope,
6
+ readCachedHash,
7
+ writeCachedHash,
8
+ } from "./commandCache";
9
+
10
+ export type RegisterMode =
11
+ | { mode: "guild"; guildId: string }
12
+ | { mode: "global" }
13
+ | { mode: "none" };
14
+
15
+ export async function registerCommands(options: {
16
+ token: string;
17
+ applicationId: string;
18
+ where: RegisterMode;
19
+ commandJson: unknown[];
20
+ /**
21
+ * If true, compare hashes and skip REST registration when unchanged.
22
+ * Defaults to true.
23
+ */
24
+ cache?: boolean;
25
+
26
+ /**
27
+ * If true, ALWAYS register regardless of cache.
28
+ * Defaults to Bun.env.DISCORD_FORCE_REGISTER === "true".
29
+ */
30
+ force?: boolean;
31
+
32
+ /**
33
+ * Optional cache partition key (ex: "development" / "production").
34
+ * If omitted, uses Bun.env.NODE_ENV (or undefined if not set).
35
+ */
36
+ envKey?: string;
37
+ }): Promise<void> {
38
+ const { token, applicationId, where, commandJson } = options;
39
+
40
+ const useCache = options.cache ?? true;
41
+ const force =
42
+ options.force ??
43
+ String(Bun.env.DISCORD_FORCE_REGISTER ?? "false").toLowerCase() === "true";
44
+
45
+ const envKey = options.envKey ?? Bun.env.NODE_ENV;
46
+
47
+ if (where.mode === "none") {
48
+ logger.info("Skipping command registration (mode: none).");
49
+ return;
50
+ }
51
+
52
+ const scope: RegisterScope =
53
+ where.mode === "global"
54
+ ? { mode: "global" }
55
+ : { mode: "guild", guildId: where.guildId };
56
+
57
+ const scopeLabel =
58
+ scope.mode === "global" ? "global" : `guild ${scope.guildId}`;
59
+
60
+ // Single REST client instance shared across all registration paths below.
61
+ const rest = new REST({ version: "10" }).setToken(token);
62
+
63
+ if (useCache && !force) {
64
+ const nextHash = hashCommandJson(commandJson);
65
+ const prevHash = await readCachedHash({
66
+ scope,
67
+ applicationId,
68
+ envKey,
69
+ });
70
+
71
+ if (prevHash && prevHash === nextHash) {
72
+ logger.info(
73
+ `Command registration skipped (no changes) for ${scopeLabel}.`,
74
+ );
75
+ return;
76
+ }
77
+
78
+ logger.info(`Commands changed; registering for ${scopeLabel}...`);
79
+
80
+ if (where.mode === "guild") {
81
+ await rest.put(
82
+ Routes.applicationGuildCommands(applicationId, where.guildId),
83
+ { body: commandJson },
84
+ );
85
+
86
+ await writeCachedHash({ scope, applicationId, envKey, hash: nextHash });
87
+
88
+ logger.info(
89
+ `Registered ${commandJson.length} guild application command(s) to ${where.guildId}.`,
90
+ );
91
+ return;
92
+ }
93
+
94
+ await rest.put(Routes.applicationCommands(applicationId), {
95
+ body: commandJson,
96
+ });
97
+
98
+ await writeCachedHash({ scope, applicationId, envKey, hash: nextHash });
99
+
100
+ logger.info(
101
+ `Registered ${commandJson.length} global application command(s). (Propagation can take time)`,
102
+ );
103
+ return;
104
+ }
105
+
106
+ // force=true intentionally bypasses the hash equality check and always
107
+ // registers, even when commands are unchanged. We still write the hash
108
+ // afterwards so the next normal startup can benefit from caching.
109
+ if (force) {
110
+ logger.warn(`Force register enabled; registering for ${scopeLabel}...`);
111
+ } else {
112
+ logger.info(`Registering for ${scopeLabel} (cache disabled)...`);
113
+ }
114
+
115
+ if (where.mode === "guild") {
116
+ await rest.put(
117
+ Routes.applicationGuildCommands(applicationId, where.guildId),
118
+ { body: commandJson },
119
+ );
120
+
121
+ const nextHash = hashCommandJson(commandJson);
122
+ await writeCachedHash({ scope, applicationId, envKey, hash: nextHash });
123
+
124
+ logger.info(
125
+ `Registered ${commandJson.length} guild application command(s) to ${where.guildId}.`,
126
+ );
127
+ return;
128
+ }
129
+
130
+ await rest.put(Routes.applicationCommands(applicationId), {
131
+ body: commandJson,
132
+ });
133
+
134
+ const nextHash = hashCommandJson(commandJson);
135
+ await writeCachedHash({ scope, applicationId, envKey, hash: nextHash });
136
+
137
+ logger.info(
138
+ `Registered ${commandJson.length} global application command(s). (Propagation can take time)`,
139
+ );
140
+ }
@@ -0,0 +1,103 @@
1
+ import type {
2
+ Precondition,
3
+ PreconditionContext,
4
+ PreconditionResult,
5
+ } from "../../@types/precondition";
6
+ import { BotPermissions } from "./preconditions/BotPermissions";
7
+ import { Cooldown } from "./preconditions/Cooldown";
8
+ import { GuildOnly } from "./preconditions/GuildOnly";
9
+ import { OwnerOnly } from "./preconditions/OwnerOnly";
10
+ import { UserPermissions } from "./preconditions/UserPermissions";
11
+
12
+ /**
13
+ * Built-in preconditions applied to every slash command automatically,
14
+ * based on the flags set in command meta. Order matters:
15
+ *
16
+ * 1. OwnerOnly — bail early for non-owners before any other check
17
+ * 2. GuildOnly — bail before guild-specific checks (perms, cooldowns)
18
+ * 3. Cooldown — check + set cooldown before executing
19
+ * 4. UserPermissions
20
+ * 5. BotPermissions
21
+ */
22
+ const BUILT_INS: Precondition[] = [
23
+ OwnerOnly,
24
+ GuildOnly,
25
+ Cooldown,
26
+ UserPermissions,
27
+ BotPermissions,
28
+ ];
29
+
30
+ export type RunPreconditionsOptions = {
31
+ ctx: PreconditionContext;
32
+ /**
33
+ * Custom preconditions registered on CommandHandler, keyed by name.
34
+ * Only the ones listed in `ctx.meta.preconditions` will be run.
35
+ */
36
+ registry: Map<string, Precondition>;
37
+ };
38
+
39
+ export type PreconditionFailure = {
40
+ message: string;
41
+ ephemeral: boolean;
42
+ };
43
+
44
+ export type RunPreconditionsResult =
45
+ | { ok: true }
46
+ | { ok: false; failure: PreconditionFailure };
47
+
48
+ /**
49
+ * Runs all applicable preconditions for a slash command invocation.
50
+ *
51
+ * Built-ins always run first (in a fixed order) based on meta flags.
52
+ * Custom preconditions from `meta.preconditions` run afterwards, in the
53
+ * order they are declared on the command.
54
+ *
55
+ * Returns on the first failure — subsequent preconditions are not evaluated.
56
+ */
57
+ export async function runPreconditions(
58
+ options: RunPreconditionsOptions,
59
+ ): Promise<RunPreconditionsResult> {
60
+ const { ctx, registry } = options;
61
+
62
+ // Run built-ins
63
+ for (const precondition of BUILT_INS) {
64
+ const result: PreconditionResult = await precondition.run(ctx);
65
+
66
+ if (!result.ok) {
67
+ return {
68
+ ok: false,
69
+ failure: {
70
+ message: result.message,
71
+ ephemeral: result.ephemeral ?? true,
72
+ },
73
+ };
74
+ }
75
+ }
76
+
77
+ // Run custom preconditions declared on this command
78
+ const customNames = ctx.meta.preconditions ?? [];
79
+
80
+ for (const name of customNames) {
81
+ const precondition = registry.get(name);
82
+
83
+ if (!precondition) {
84
+ throw new Error(
85
+ `Precondition "${name}" is referenced by command "${ctx.interaction.commandName}" but was never registered on CommandHandler.`,
86
+ );
87
+ }
88
+
89
+ const result: PreconditionResult = await precondition.run(ctx);
90
+
91
+ if (!result.ok) {
92
+ return {
93
+ ok: false,
94
+ failure: {
95
+ message: result.message,
96
+ ephemeral: result.ephemeral ?? true,
97
+ },
98
+ };
99
+ }
100
+ }
101
+
102
+ return { ok: true };
103
+ }
@@ -0,0 +1,20 @@
1
+ export type {
2
+ Precondition,
3
+ PreconditionContext,
4
+ PreconditionResult,
5
+ } from "../@types/precondition";
6
+ export type { CommandHandlerOptions } from "./CommandHandler";
7
+ export { CommandHandler } from "./CommandHandler";
8
+ export {
9
+ DISCORD_CUSTOM_ID_MAX_LENGTH,
10
+ makeCustomId,
11
+ parseCustomId,
12
+ } from "./functions/customId";
13
+ export { BotPermissions } from "./functions/preconditions/BotPermissions";
14
+ export { Cooldown } from "./functions/preconditions/Cooldown";
15
+ export { GuildOnly } from "./functions/preconditions/GuildOnly";
16
+ // if they want to compose or extend them in custom preconditions.
17
+ export { OwnerOnly } from "./functions/preconditions/OwnerOnly";
18
+ export { UserPermissions } from "./functions/preconditions/UserPermissions";
19
+ export type { RegisterMode } from "./functions/registerCommands";
20
+ export { UserError } from "./functions/UserError";
@@ -0,0 +1,172 @@
1
+ import { Collection, PermissionsBitField } from "discord.js";
2
+ import type { ClientClass } from "../../structure/Client";
3
+ import type { ContextMenuCommand } from "../@types/contextMenu";
4
+ import type { Precondition } from "../@types/precondition";
5
+ import { CooldownStore } from "../command/functions/cooldowns";
6
+ import { parseOwnerIds } from "../command/functions/owners";
7
+ import {
8
+ type RegisterMode,
9
+ registerCommands,
10
+ } from "../command/functions/registerCommands";
11
+ import {
12
+ type AttachedContextMenuListener,
13
+ attachContextMenuListener,
14
+ } from "./functions/attachContextMenuListener";
15
+ import { loadContextMenusFromDisk } from "./functions/loadContextMenus";
16
+
17
+ export type ContextMenuHandlerOptions = {
18
+ client: ClientClass;
19
+
20
+ contextMenusDir: string;
21
+
22
+ register?: {
23
+ token: string;
24
+ applicationId: string;
25
+ where: RegisterMode;
26
+ };
27
+
28
+ extensions?: string[];
29
+
30
+ ownerIds?: string[];
31
+
32
+ /**
33
+ * If true (default), skip REST registration when commands haven't changed.
34
+ */
35
+ registrationCache?: boolean;
36
+ };
37
+
38
+ export class ContextMenuHandler {
39
+ public readonly commands = new Collection<string, ContextMenuCommand>();
40
+
41
+ private readonly client: ClientClass;
42
+ private readonly contextMenusDir: string;
43
+ private readonly registerConfig?: ContextMenuHandlerOptions["register"];
44
+ private readonly extensions: string[];
45
+ private readonly ownerIds: Set<string>;
46
+ private readonly cooldowns = new CooldownStore();
47
+ private readonly registrationCache: boolean;
48
+
49
+ /**
50
+ * Shared precondition registry. If you pass the same Map instance used by
51
+ * CommandHandler, all registered preconditions are available to both
52
+ * slash commands and context menus automatically.
53
+ */
54
+ private readonly preconditionRegistry: Map<string, Precondition>;
55
+ private attachedListener?: AttachedContextMenuListener;
56
+
57
+ private initialized = false;
58
+
59
+ constructor(
60
+ options: ContextMenuHandlerOptions & {
61
+ /**
62
+ * Optionally pass CommandHandler's precondition registry to share
63
+ * custom preconditions across both handlers.
64
+ */
65
+ preconditionRegistry?: Map<string, Precondition>;
66
+ },
67
+ ) {
68
+ this.client = options.client;
69
+ this.contextMenusDir = options.contextMenusDir;
70
+ this.registerConfig = options.register;
71
+ this.extensions = options.extensions ?? [".ts"];
72
+ this.preconditionRegistry =
73
+ options.preconditionRegistry ?? new Map<string, Precondition>();
74
+
75
+ this.ownerIds = options.ownerIds
76
+ ? new Set(options.ownerIds)
77
+ : parseOwnerIds(Bun.env.DISCORD_OWNER_IDS);
78
+
79
+ this.registrationCache = options.registrationCache ?? true;
80
+ }
81
+
82
+ /**
83
+ * Register a custom precondition so context menus can reference it by name.
84
+ * Must be called before `init()`. Returns `this` for chaining.
85
+ */
86
+ public registerPrecondition(precondition: Precondition): this {
87
+ if (this.initialized) {
88
+ throw new Error(
89
+ `Cannot register precondition "${precondition.name}" after init() has been called.`,
90
+ );
91
+ }
92
+
93
+ if (this.preconditionRegistry.has(precondition.name)) {
94
+ throw new Error(
95
+ `Precondition "${precondition.name}" is already registered.`,
96
+ );
97
+ }
98
+
99
+ this.preconditionRegistry.set(precondition.name, precondition);
100
+ return this;
101
+ }
102
+
103
+ public async init(): Promise<void> {
104
+ if (this.initialized) {
105
+ throw new Error("ContextMenuHandler.init() was called more than once.");
106
+ }
107
+ this.initialized = true;
108
+
109
+ const commands = await loadContextMenusFromDisk({
110
+ contextMenusDir: this.contextMenusDir,
111
+ extensions: this.extensions,
112
+ });
113
+
114
+ for (const [name, cmd] of commands) {
115
+ this.commands.set(name, cmd);
116
+ }
117
+
118
+ this.attachedListener = attachContextMenuListener({
119
+ client: this.client,
120
+ commands: this.commands,
121
+ ownerIds: this.ownerIds,
122
+ cooldowns: this.cooldowns,
123
+ preconditionRegistry: this.preconditionRegistry,
124
+ });
125
+
126
+ if (this.registerConfig) {
127
+ await registerCommands({
128
+ token: this.registerConfig.token,
129
+ applicationId: this.registerConfig.applicationId,
130
+ where: this.registerConfig.where,
131
+ commandJson: this.toRegistrationJson(),
132
+ cache: this.registrationCache,
133
+ });
134
+ }
135
+ }
136
+
137
+ public detach(): void {
138
+ if (!this.attachedListener) return;
139
+
140
+ this.client.off(
141
+ this.attachedListener.eventName,
142
+ this.attachedListener.fn as never,
143
+ );
144
+ this.attachedListener = undefined;
145
+ }
146
+
147
+ /**
148
+ * Serialise to plain JSON for Discord registration.
149
+ * Permissions are applied to the JSON object, not the builder,
150
+ * so the in-memory store is never mutated as a side effect.
151
+ * Sorted by name for a stable hash regardless of load order.
152
+ */
153
+ public toRegistrationJson(): unknown[] {
154
+ const out: Array<Record<string, unknown>> = [];
155
+
156
+ for (const cmd of this.commands.values()) {
157
+ const json = cmd.data.toJSON() as unknown as Record<string, unknown>;
158
+
159
+ const perms = cmd.meta?.defaultMemberPermissions;
160
+ if (perms && perms.length > 0) {
161
+ const bitfield = new PermissionsBitField(perms).bitfield;
162
+ json.default_member_permissions = String(bitfield);
163
+ }
164
+
165
+ out.push(json);
166
+ }
167
+
168
+ return out.sort((a, b) =>
169
+ String(a.name ?? "").localeCompare(String(b.name ?? "")),
170
+ );
171
+ }
172
+ }
@@ -0,0 +1,174 @@
1
+ import type {
2
+ Collection,
3
+ InteractionReplyOptions,
4
+ MessageContextMenuCommandInteraction,
5
+ UserContextMenuCommandInteraction,
6
+ } from "discord.js";
7
+ import type { ClientClass } from "../../../structure/Client";
8
+ import { logger } from "../../../utils";
9
+ import type { ContextMenuCommand } from "../../@types/contextMenu";
10
+ import type { Precondition } from "../../@types/precondition";
11
+ import type { CooldownStore } from "../../command/functions/cooldowns";
12
+ import { isUserError } from "../../command/functions/UserError";
13
+ import { runContextMenuPreconditions } from "./runContextMenuPreconditions";
14
+
15
+ type AnyContextMenuInteraction =
16
+ | UserContextMenuCommandInteraction
17
+ | MessageContextMenuCommandInteraction;
18
+
19
+ type Repliable = {
20
+ deferred: boolean;
21
+ replied: boolean;
22
+ reply: (options: InteractionReplyOptions) => Promise<unknown>;
23
+ followUp: (options: InteractionReplyOptions) => Promise<unknown>;
24
+ };
25
+
26
+ export type AttachedContextMenuListener = {
27
+ eventName: "interactionCreate";
28
+ fn: (interaction: any) => void;
29
+ };
30
+
31
+ async function replySafe(
32
+ interaction: Repliable,
33
+ payload: InteractionReplyOptions,
34
+ ): Promise<void> {
35
+ if (interaction.deferred || interaction.replied) {
36
+ await interaction.followUp(payload);
37
+ } else {
38
+ await interaction.reply(payload);
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Shared routing logic for both user and message context menu interactions.
44
+ * Previously duplicated across two identical blocks.
45
+ */
46
+ async function routeContextMenuInteraction<
47
+ T extends AnyContextMenuInteraction,
48
+ >(options: {
49
+ interaction: T;
50
+ command: ContextMenuCommand & {
51
+ execute: (ctx: { interaction: T; client: ClientClass }) => Promise<void>;
52
+ };
53
+ client: ClientClass;
54
+ ownerIds: Set<string>;
55
+ cooldowns: CooldownStore;
56
+ preconditionRegistry: Map<string, Precondition>;
57
+ label: string;
58
+ }): Promise<void> {
59
+ const {
60
+ interaction,
61
+ command,
62
+ client,
63
+ ownerIds,
64
+ cooldowns,
65
+ preconditionRegistry,
66
+ label,
67
+ } = options;
68
+
69
+ const result = await runContextMenuPreconditions({
70
+ ctx: {
71
+ interaction,
72
+ client,
73
+ meta: command.meta ?? {},
74
+ ownerIds,
75
+ cooldowns,
76
+ },
77
+ registry: preconditionRegistry,
78
+ });
79
+
80
+ if (!result.ok) {
81
+ await replySafe(interaction, {
82
+ content: result.failure.message,
83
+ ephemeral: result.failure.ephemeral,
84
+ });
85
+ return;
86
+ }
87
+
88
+ try {
89
+ await command.execute({ interaction, client });
90
+ } catch (err) {
91
+ if (isUserError(err)) {
92
+ await replySafe(interaction, {
93
+ content: err.message,
94
+ ephemeral: err.ephemeral,
95
+ });
96
+ return;
97
+ }
98
+
99
+ logger.error(
100
+ `Error executing ${label} context menu "${interaction.commandName}"`,
101
+ err,
102
+ );
103
+
104
+ await replySafe(interaction, {
105
+ content: "Something went wrong.",
106
+ ephemeral: true,
107
+ });
108
+ }
109
+ }
110
+
111
+ export function attachContextMenuListener(options: {
112
+ client: ClientClass;
113
+ commands: Collection<string, ContextMenuCommand>;
114
+ ownerIds: Set<string>;
115
+ cooldowns: CooldownStore;
116
+ preconditionRegistry: Map<string, Precondition>;
117
+ }): AttachedContextMenuListener {
118
+ const { client, commands, ownerIds, cooldowns, preconditionRegistry } =
119
+ options;
120
+
121
+ const fn = async (interaction: any) => {
122
+ // ---- User context menu ----
123
+ if (interaction.isUserContextMenuCommand()) {
124
+ const command = commands.get(interaction.commandName);
125
+
126
+ if (!command || command.type !== "user") {
127
+ logger.warn(
128
+ `Received unknown user context menu: "${interaction.commandName}"`,
129
+ );
130
+ return;
131
+ }
132
+
133
+ await routeContextMenuInteraction({
134
+ interaction,
135
+ command,
136
+ client,
137
+ ownerIds,
138
+ cooldowns,
139
+ preconditionRegistry,
140
+ label: "user",
141
+ });
142
+ return;
143
+ }
144
+
145
+ // ---- Message context menu ----
146
+ if (interaction.isMessageContextMenuCommand()) {
147
+ const command = commands.get(interaction.commandName);
148
+
149
+ if (!command || command.type !== "message") {
150
+ logger.warn(
151
+ `Received unknown message context menu: "${interaction.commandName}"`,
152
+ );
153
+ return;
154
+ }
155
+
156
+ await routeContextMenuInteraction({
157
+ interaction,
158
+ command,
159
+ client,
160
+ ownerIds,
161
+ cooldowns,
162
+ preconditionRegistry,
163
+ label: "message",
164
+ });
165
+ }
166
+ };
167
+
168
+ client.on("interactionCreate", fn);
169
+
170
+ return {
171
+ eventName: "interactionCreate",
172
+ fn,
173
+ };
174
+ }