Skip to content

Commit

Permalink
feat(ApplicationCommandRegistries): add `RegisterBehavior.BulkOverwri…
Browse files Browse the repository at this point in the history
…te` which has Sapphire use bulk overwrite for registering Application Commands (#585)

Co-authored-by: Jeroen Claassens <support@favware.tech>
  • Loading branch information
vladfrangu and favna committed Jan 5, 2023
1 parent bfa3561 commit 9f0ef5e
Show file tree
Hide file tree
Showing 4 changed files with 191 additions and 5 deletions.
14 changes: 13 additions & 1 deletion src/lib/types/Enums.ts
Expand Up @@ -43,7 +43,19 @@ export enum RegisterBehavior {
* @danger This can potentially cause slowdowns when booting up your bot as computing differences on big commands can take a while.
* We recommend you use `OVERWRITE` instead in production.
*/
VerboseOverwrite = 'VERBOSE_OVERWRITE'
VerboseOverwrite = 'VERBOSE_OVERWRITE',
/**
* Makes Sapphire handle all command registrations, removals, and updates for you.
*
* This mode can only be set as the **default** behavior, and cannot be set per-command.
*
* In this mode:
* - any `idHints` set per-command are no longer respected, and can be omitted.
* - any `behaviorWhenNotIdentical` that are set per-command are no longer respected, and can be omitted.
* - any application commands that are *not* registered through Sapphire's {@link ApplicationCommandRegistry} are removed from the application.
* - the same applies for guild commands, but only for guilds that are registered in the registry via `guildIds`.
*/
BulkOverwrite = 'BULK_OVERWRITE'
}

export const enum InternalRegistryAPIType {
Expand Down
120 changes: 119 additions & 1 deletion src/lib/utils/application-commands/ApplicationCommandRegistries.ts
@@ -1,8 +1,13 @@
import { container } from '@sapphire/pieces';
import type { RESTPostAPIApplicationCommandsJSONBody } from 'discord-api-types/v10';
import type { ApplicationCommandManager } from 'discord.js';
import type { Command } from '../../structures/Command';
import type { CommandStore } from '../../structures/CommandStore';
import { RegisterBehavior } from '../../types/Enums';
import { ApplicationCommandRegistry } from './ApplicationCommandRegistry';
import { emitRegistryError } from './emitRegistryError';
import { getNeededRegistryParameters } from './getNeededParameters';
import { bulkOverwriteDebug, bulkOverwriteError, bulkOverwriteInfo, bulkOverwriteWarn } from './registriesLog';

export let defaultBehaviorWhenNotIdentical = RegisterBehavior.Overwrite;

Expand Down Expand Up @@ -53,8 +58,116 @@ export async function handleRegistryAPICalls() {
}
}

const { applicationCommands, globalCommands, guildCommands } = await getNeededRegistryParameters(allGuildIdsToFetchCommandsFor);
if (defaultBehaviorWhenNotIdentical === RegisterBehavior.BulkOverwrite) {
await handleBulkOverwrite(commandStore, container.client.application!.commands);
return;
}

const params = await getNeededRegistryParameters(allGuildIdsToFetchCommandsFor);

await handleAppendOrUpdate(commandStore, params);
}

async function handleBulkOverwrite(commandStore: CommandStore, applicationCommands: ApplicationCommandManager) {
// Map registries by guild, global, etc
const foundGlobalCommands: BulkOverwriteData[] = [];
const foundGuildCommands: Record<string, BulkOverwriteData[]> = {};

// Collect all data
for (const command of commandStore.values()) {
const registry = command.applicationCommandRegistry;

for (const call of registry['apiCalls']) {
// Guild only cmd
if (call.registerOptions.guildIds?.length) {
for (const guildId of call.registerOptions.guildIds) {
foundGuildCommands[guildId] ??= [];

foundGuildCommands[guildId].push({ piece: command, data: call.builtData });
}
continue;
}

// Global command
foundGlobalCommands.push({ piece: command, data: call.builtData });
}
}

// Handle global commands
try {
bulkOverwriteDebug(`Overwriting global application commands, now at ${foundGlobalCommands.length} commands`);
const result = await applicationCommands.set(foundGlobalCommands.map((x) => x.data));

// Go through each registered command, find its piece and alias it
for (const [id, globalCommand] of result.entries()) {
const piece = foundGlobalCommands.find((x) => x.data.name === globalCommand.name)?.piece;

if (piece) {
const registry = piece.applicationCommandRegistry;

registry.globalCommandId = id;
registry.addChatInputCommandIds(id);

// idHints are useless, and any manually added id or names could end up not being valid anymore if you use bulk overwrites
// That said, this might be an issue, so we might need to do it like `handleAppendOrUpdate`
commandStore.aliases.set(id, piece);
} else {
bulkOverwriteWarn(
`Registered global command "${globalCommand.name}" (${id}) but failed to find the piece in the command store. This should not happen`
);
}
}

bulkOverwriteInfo(`Successfully overwrote global application commands. The application now has ${result.size} global commands`);
} catch (error) {
bulkOverwriteError(`Failed to overwrite global application commands`, error);
}

// Handle guild commands
for (const [guildId, guildCommands] of Object.entries(foundGuildCommands)) {
try {
bulkOverwriteDebug(`Overwriting guild application commands for guild ${guildId}, now at ${guildCommands.length} commands`);
const result = await applicationCommands.set(
guildCommands.map((x) => x.data),
guildId
);

// Go through each registered command, find its piece and alias it
for (const [id, guildCommand] of result.entries()) {
// I really hope nobody has a guild command with the same name as another command -.-
// Not like they could anyways as Discord would throw an error for duplicate names
// But yknow... If you're reading this and you did this... Why?
const piece = guildCommands.find((x) => x.data.name === guildCommand.name)?.piece;

if (piece) {
const registry = piece.applicationCommandRegistry;
registry.guildCommandIds.set(guildId, id);

registry.addChatInputCommandIds(id);

// idHints are useless, and any manually added ids or names could no longer be valid if you use bulk overwrites.
// That said, this might be an issue, so we might need to do it like `handleAppendOrUpdate`
commandStore.aliases.set(id, piece);
} else {
bulkOverwriteWarn(
`Registered guild command "${guildCommand.name}" (${id}) but failed to find the piece in the command store. This should not happen`
);
}
}

bulkOverwriteInfo(
`Successfully overwrote guild application commands for guild ${guildId}. The application now has ${result.size} guild commands for guild ${guildId}`
);
} catch (error) {
bulkOverwriteError(`Failed to overwrite guild application commands for guild ${guildId}`, error);
}
}
}

async function handleAppendOrUpdate(
commandStore: CommandStore,
{ applicationCommands, globalCommands, guildCommands }: Awaited<ReturnType<typeof getNeededRegistryParameters>>
) {
for (const registry of registries.values()) {
// eslint-disable-next-line @typescript-eslint/dot-notation
await registry['runAPICalls'](applicationCommands, globalCommands, guildCommands);
Expand All @@ -72,3 +185,8 @@ export async function handleRegistryAPICalls() {
}
}
}

interface BulkOverwriteData {
piece: Command;
data: RESTPostAPIApplicationCommandsJSONBody;
}
45 changes: 42 additions & 3 deletions src/lib/utils/application-commands/ApplicationCommandRegistry.ts
Expand Up @@ -32,6 +32,9 @@ export class ApplicationCommandRegistry {
public readonly contextMenuCommands = new Set<string>();
public readonly guildIdsToFetch = new Set<string>();

public globalCommandId: string | null = null;
public readonly guildCommandIds = new Map<string, string>();

private readonly apiCalls: InternalAPICall[] = [];

public constructor(commandName: string) {
Expand Down Expand Up @@ -197,6 +200,12 @@ export class ApplicationCommandRegistry {
return;
}

if (getDefaultBehaviorWhenNotIdentical() === RegisterBehavior.BulkOverwrite) {
throw new RangeError(
`"runAPICalls" was called for "${this.commandName}" but the defaultBehaviorWhenNotIdentical is "BulkOverwrite". This should not happen.`
);
}

this.debug(`Preparing to process ${this.apiCalls.length} possible command registrations / updates...`);

const results = await Promise.allSettled(
Expand Down Expand Up @@ -338,6 +347,26 @@ export class ApplicationCommandRegistry {
behaviorIfNotEqual: RegisterBehavior,
guildId: string | null
) {
if (guildId) {
this.guildCommandIds.set(guildId, applicationCommand.id);
} else {
this.globalCommandId = applicationCommand.id;
}

if (behaviorIfNotEqual === RegisterBehavior.BulkOverwrite) {
this.debug(
`Command "${this.commandName}" has the behaviorIfNotEqual set to "BulkOverwrite" which is invalid. Using defaultBehaviorWhenNotIdentical instead`
);

behaviorIfNotEqual = getDefaultBehaviorWhenNotIdentical();

if (behaviorIfNotEqual === RegisterBehavior.BulkOverwrite) {
throw new Error(
`Invalid behaviorIfNotEqual value ("BulkOverwrite") for command "${this.commandName}", and defaultBehaviorWhenNotIdentical is also "BulkOverwrite". This should not happen.`
);
}
}

let differences: CommandDifference[] = [];

if (behaviorIfNotEqual === RegisterBehavior.VerboseOverwrite) {
Expand Down Expand Up @@ -429,6 +458,12 @@ export class ApplicationCommandRegistry {
// @ts-ignore Currently there's a discord-api-types version clash between builders and discord.js
const result = await commandsManager.create(apiData, guildId);

if (guildId) {
this.guildCommandIds.set(guildId, result.id);
} else {
this.globalCommandId = result.id;
}

this.info(
`Successfully created ${type}${guildId ? ' guild' : ''} command "${apiData.name}" with id "${
result.id
Expand Down Expand Up @@ -491,7 +526,7 @@ export namespace ApplicationCommandRegistry {
* Specifies what we should do when the command is present, but not identical with the data you provided
* @default `ApplicationCommandRegistries.getDefaultBehaviorWhenNotIdentical()`
*/
behaviorWhenNotIdentical?: RegisterBehavior;
behaviorWhenNotIdentical?: Exclude<RegisterBehavior, RegisterBehavior.BulkOverwrite>;
/**
* Specifies a list of command ids that we should check in the event of a name mismatch
* @default []
Expand All @@ -502,14 +537,18 @@ export namespace ApplicationCommandRegistry {

export type ApplicationCommandRegistryRegisterOptions = ApplicationCommandRegistry.RegisterOptions;

type InternalRegisterOptions = Omit<ApplicationCommandRegistry.RegisterOptions, 'behaviorWhenNotIdentical'> & {
behaviorWhenNotIdentical?: RegisterBehavior;
};

export type InternalAPICall =
| {
builtData: RESTPostAPIChatInputApplicationCommandsJSONBody;
registerOptions: ApplicationCommandRegistryRegisterOptions;
registerOptions: InternalRegisterOptions;
type: InternalRegistryAPIType.ChatInput;
}
| {
builtData: RESTPostAPIContextMenuApplicationCommandsJSONBody;
registerOptions: ApplicationCommandRegistryRegisterOptions;
registerOptions: InternalRegisterOptions;
type: InternalRegistryAPIType.ContextMenu;
};
17 changes: 17 additions & 0 deletions src/lib/utils/application-commands/registriesLog.ts
@@ -0,0 +1,17 @@
import { container } from '@sapphire/pieces';

export function bulkOverwriteInfo(message: string, ...other: unknown[]) {
container.logger.info(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other);
}

export function bulkOverwriteError(message: string, ...other: unknown[]) {
container.logger.error(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other);
}

export function bulkOverwriteWarn(message: string, ...other: unknown[]) {
container.logger.warn(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other);
}

export function bulkOverwriteDebug(message: string, ...other: unknown[]) {
container.logger.debug(`ApplicationCommandRegistries(BulkOverwrite) ${message}`, ...other);
}

0 comments on commit 9f0ef5e

Please sign in to comment.