From ff7e806dc6c71b47662961a16bc5911a2c06ede3 Mon Sep 17 00:00:00 2001 From: LoneWeeb <73281112+Tharki-God@users.noreply.github.com> Date: Fri, 15 Sep 2023 03:00:49 +0530 Subject: [PATCH] Slash command api (#501) --- cspell.json | 1 + pnpm-lock.yaml | 70 +-- src/renderer/apis/commands.ts | 256 ++++++++++- src/renderer/coremods/commands/commands.ts | 418 ++++++++++++++++++ src/renderer/coremods/commands/index.ts | 291 ++++++++++++ .../coremods/commands/plaintextPatches.ts | 14 + src/renderer/managers/coremods.ts | 4 + src/renderer/modules/common/constants.ts | 4 + src/renderer/modules/common/i18n.ts | 2 +- src/renderer/modules/common/messages.ts | 52 ++- src/renderer/modules/injector.ts | 43 +- src/renderer/replugged.ts | 1 - src/types/coremods/commands.ts | 97 ++++ src/types/discord.ts | 136 +++++- src/types/index.ts | 11 +- src/types/util.ts | 6 - 16 files changed, 1318 insertions(+), 88 deletions(-) create mode 100644 src/renderer/coremods/commands/commands.ts create mode 100644 src/renderer/coremods/commands/index.ts create mode 100644 src/renderer/coremods/commands/plaintextPatches.ts create mode 100644 src/types/coremods/commands.ts diff --git a/cspell.json b/cspell.json index f5f71c635..2e4895344 100644 --- a/cspell.json +++ b/cspell.json @@ -29,6 +29,7 @@ "fontawesome", "Fonticons", "getent", + "gifv", "globstar", "gpgsign", "groupstart", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f944749f8..cc3c374b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -117,10 +117,10 @@ devDependencies: version: 17.0.24 '@typescript-eslint/eslint-plugin': specifier: ^5.62.0 - version: 5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.48.0)(typescript@5.1.6) + version: 5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.48.0)(typescript@5.2.2) '@typescript-eslint/parser': specifier: ^5.62.0 - version: 5.62.0(eslint@8.48.0)(typescript@5.1.6) + version: 5.62.0(eslint@8.48.0)(typescript@5.2.2) cspell: specifier: ^6.31.3 version: 6.31.3 @@ -165,10 +165,10 @@ devDependencies: version: 3.13.1 typedoc: specifier: ^0.23.28 - version: 0.23.28(typescript@5.1.6) + version: 0.23.28(typescript@5.2.2) typescript: specifier: ^5.1.6 - version: 5.1.6 + version: 5.2.2 packages: @@ -2473,7 +2473,7 @@ packages: '@types/yargs-parser': 21.0.0 dev: true - /@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.48.0)(typescript@5.1.6): + /@typescript-eslint/eslint-plugin@5.62.0(@typescript-eslint/parser@5.62.0)(eslint@8.48.0)(typescript@5.2.2): resolution: {integrity: sha512-TiZzBSJja/LbhNPvk6yc0JrX9XqhQ0hdh6M2svYfsHGejaKFIAGd9MQ+ERIMzLGlN/kZoYIgdxFV0PuljTKXag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -2485,23 +2485,23 @@ packages: optional: true dependencies: '@eslint-community/regexpp': 4.8.0 - '@typescript-eslint/parser': 5.62.0(eslint@8.48.0)(typescript@5.1.6) + '@typescript-eslint/parser': 5.62.0(eslint@8.48.0)(typescript@5.2.2) '@typescript-eslint/scope-manager': 5.62.0 - '@typescript-eslint/type-utils': 5.62.0(eslint@8.48.0)(typescript@5.1.6) - '@typescript-eslint/utils': 5.62.0(eslint@8.48.0)(typescript@5.1.6) + '@typescript-eslint/type-utils': 5.62.0(eslint@8.48.0)(typescript@5.2.2) + '@typescript-eslint/utils': 5.62.0(eslint@8.48.0)(typescript@5.2.2) debug: 4.3.4 eslint: 8.48.0 graphemer: 1.4.0 ignore: 5.2.4 natural-compare-lite: 1.4.0 semver: 7.5.4 - tsutils: 3.21.0(typescript@5.1.6) - typescript: 5.1.6 + tsutils: 3.21.0(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/parser@5.62.0(eslint@8.48.0)(typescript@5.1.6): + /@typescript-eslint/parser@5.62.0(eslint@8.48.0)(typescript@5.2.2): resolution: {integrity: sha512-VlJEV0fOQ7BExOsHYAGrgbEiZoi8D+Bl2+f6V2RrXerRSylnp+ZBHmPvaIa8cz0Ajx7WO7Z5RqfgYg7ED1nRhA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -2513,10 +2513,10 @@ packages: dependencies: '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.2.2) debug: 4.3.4 eslint: 8.48.0 - typescript: 5.1.6 + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true @@ -2529,7 +2529,7 @@ packages: '@typescript-eslint/visitor-keys': 5.62.0 dev: true - /@typescript-eslint/type-utils@5.62.0(eslint@8.48.0)(typescript@5.1.6): + /@typescript-eslint/type-utils@5.62.0(eslint@8.48.0)(typescript@5.2.2): resolution: {integrity: sha512-xsSQreu+VnfbqQpW5vnCJdq1Z3Q0U31qiWmRhr98ONQmcp/yhiPJFPq8MXiJVLiksmOKSjIldZzkebzHuCGzew==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -2539,12 +2539,12 @@ packages: typescript: optional: true dependencies: - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.1.6) - '@typescript-eslint/utils': 5.62.0(eslint@8.48.0)(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.2.2) + '@typescript-eslint/utils': 5.62.0(eslint@8.48.0)(typescript@5.2.2) debug: 4.3.4 eslint: 8.48.0 - tsutils: 3.21.0(typescript@5.1.6) - typescript: 5.1.6 + tsutils: 3.21.0(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true @@ -2554,7 +2554,7 @@ packages: engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} dev: true - /@typescript-eslint/typescript-estree@5.62.0(typescript@5.1.6): + /@typescript-eslint/typescript-estree@5.62.0(typescript@5.2.2): resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -2569,13 +2569,13 @@ packages: globby: 11.1.0 is-glob: 4.0.3 semver: 7.5.4 - tsutils: 3.21.0(typescript@5.1.6) - typescript: 5.1.6 + tsutils: 3.21.0(typescript@5.2.2) + typescript: 5.2.2 transitivePeerDependencies: - supports-color dev: true - /@typescript-eslint/utils@5.62.0(eslint@8.48.0)(typescript@5.1.6): + /@typescript-eslint/utils@5.62.0(eslint@8.48.0)(typescript@5.2.2): resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: @@ -2586,7 +2586,7 @@ packages: '@types/semver': 7.5.0 '@typescript-eslint/scope-manager': 5.62.0 '@typescript-eslint/types': 5.62.0 - '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.1.6) + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.2.2) eslint: 8.48.0 eslint-scope: 5.1.1 semver: 7.5.4 @@ -2643,8 +2643,8 @@ packages: uri-js: 4.4.1 dev: true - /all-package-names@2.0.720: - resolution: {integrity: sha512-+7AsuNLAVbpHsj+qxf/p0kSqj3uW29AOsSLrS8LdikXREkZichjCOiTTr7JQj39KUlVqQsJ6NzxhnizK0N8weA==} + /all-package-names@2.0.721: + resolution: {integrity: sha512-g7KDA6eNNzY07X39kfiotxns4etbl3JTe9T+lVS+ki0xcQPPyMFNuiM1JHi2rSdJiukhauzKsCMqT/htI6ma0A==} hasBin: true dependencies: commander-version: 1.1.0 @@ -3720,8 +3720,8 @@ packages: which-typed-array: 1.1.11 dev: true - /es-iterator-helpers@1.0.13: - resolution: {integrity: sha512-LK3VGwzvaPWobO8xzXXGRUOGw8Dcjyfk62CsY/wfHN75CwsJPbuypOYJxK6g5RyEL8YDjIWcl6jgd8foO6mmrA==} + /es-iterator-helpers@1.0.14: + resolution: {integrity: sha512-JgtVnwiuoRuzLvqelrvN3Xu7H9bu2ap/kQ2CrM62iidP8SKuD99rWU3CJy++s7IVL2qb/AjXPGR/E7i9ngd/Cw==} dependencies: asynciterator.prototype: 1.0.0 call-bind: 1.0.2 @@ -3892,7 +3892,7 @@ packages: array.prototype.flatmap: 1.3.1 array.prototype.tosorted: 1.1.1 doctrine: 2.1.0 - es-iterator-helpers: 1.0.13 + es-iterator-helpers: 1.0.14 eslint: 8.48.0 estraverse: 5.3.0 jsx-ast-utils: 3.3.5 @@ -4877,7 +4877,7 @@ packages: /is-name-taken@2.0.0: resolution: {integrity: sha512-W+FUWF5g7ONVJTx3rldZeVizmPzrMMUdscpSQ96vyYerx+4b2NcqaujLJJDWruGzE0FjzGZO9RFIipOGxx/WIw==} dependencies: - all-package-names: 2.0.720 + all-package-names: 2.0.721 package-name-conflict: 1.0.3 validate-npm-package-name: 3.0.0 dev: true @@ -6852,14 +6852,14 @@ packages: resolution: {integrity: sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==} dev: true - /tsutils@3.21.0(typescript@5.1.6): + /tsutils@3.21.0(typescript@5.2.2): resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} engines: {node: '>= 6'} peerDependencies: typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' dependencies: tslib: 1.14.1 - typescript: 5.1.6 + typescript: 5.2.2 dev: true /tsx@3.12.7: @@ -6946,7 +6946,7 @@ packages: dependencies: is-typedarray: 1.0.0 - /typedoc@0.23.28(typescript@5.1.6): + /typedoc@0.23.28(typescript@5.2.2): resolution: {integrity: sha512-9x1+hZWTHEQcGoP7qFmlo4unUoVJLB0H/8vfO/7wqTnZxg4kPuji9y3uRzEu0ZKez63OJAUmiGhUrtukC6Uj3w==} engines: {node: '>= 14.14'} hasBin: true @@ -6957,7 +6957,7 @@ packages: marked: 4.3.0 minimatch: 7.4.6 shiki: 0.14.3 - typescript: 5.1.6 + typescript: 5.2.2 dev: true /types-eslintrc@1.0.3: @@ -6977,8 +6977,8 @@ packages: types-json: 1.2.2 dev: true - /typescript@5.1.6: - resolution: {integrity: sha512-zaWCozRZ6DLEWAWFrVDz1H6FVXzUSfTy5FUMWsQlU8Ym5JP9eO4xkTIROFCQvhQf61z6O/G6ugw3SgAnvvm+HA==} + /typescript@5.2.2: + resolution: {integrity: sha512-mI4WrpHsbCIcwT9cF4FZvr80QUeKvsUsUvKDoR+X/7XHQH98xYD8YHZg7ANtz2GtZt/CBq2QJ0thkGJMHfqc1w==} engines: {node: '>=14.17'} hasBin: true dev: true diff --git a/src/renderer/apis/commands.ts b/src/renderer/apis/commands.ts index ca761bb8a..fc13ba012 100644 --- a/src/renderer/apis/commands.ts +++ b/src/renderer/apis/commands.ts @@ -1,31 +1,251 @@ -import type { RepluggedCommand } from "../../types"; +import type { Channel, Guild } from "discord-types/general"; +import type { + AnyRepluggedCommand, + CommandOptionReturn, + CommandOptions, + GetCommandOption, + GetCommandOptions, + GetValueType, + RepluggedCommand, + RepluggedCommandResult, + RepluggedCommandSection, +} from "../../types"; +import { ApplicationCommandOptionType } from "../../types"; +import { constants, i18n, messages, users } from "../modules/common"; +import type { Store } from "../modules/common/flux"; import { Logger } from "../modules/logger"; +import { getByStoreName } from "../modules/webpack"; -const commandsLogger = Logger.api("Commands"); +const logger = Logger.api("Commands"); -class CommandsAPI extends EventTarget { - public commands = new Map(); +interface CommandsAndSection { + section: RepluggedCommandSection; + commands: Map; +} + +export const commandAndSections = new Map(); - public registerCommand(command: RepluggedCommand): void { - if (this.commands.has(command.name)) { - commandsLogger.error(`Command “${command.name}” is already registered!`); - return; +export const defaultSection: RepluggedCommandSection = Object.freeze({ + id: "replugged", + name: "Replugged", + type: 1, + icon: "https://cdn.discordapp.com/attachments/1000955992068079716/1004196106055454820/Replugged-Logo.png", +}); + +export class CommandInteraction { + public options: T[]; + public channel: Channel; + public guild: Guild; + public constructor(props: { options: T[]; channel: Channel; guild: Guild }) { + const UploadAttachmentStore = getByStoreName< + Store & { + getUpload: ( + channelId: string, + optionName: string, + draftType: 0, + ) => { uploadedFilename?: string; item?: { file: File } }; + } + >("UploadAttachmentStore")!; + this.options = props.options; + this.channel = props.channel; + this.guild = props.guild; + for (const option of this.options.filter( + (o) => o.type === ApplicationCommandOptionType.Attachment, + )) { + const { uploadedFilename, item } = + UploadAttachmentStore.getUpload(props.channel.id, option.name, 0) ?? {}; + option.value = { uploadedFilename, file: item?.file }; } + } - this.commands.set(command.name, command); - this.dispatchEvent(new CustomEvent("rpCommandAdded", { detail: { command } })); + public getValue( + name: K, + defaultValue?: D, + ): GetValueType, D> { + return (this.options.find((o) => o.name === name)?.value ?? defaultValue) as GetValueType< + GetCommandOption, + D + >; } +} + +/** + * @internal + * @hidden + */ +async function executeCommand( + cmdExecutor: + | (( + interaction: CommandInteraction>, + ) => Promise | RepluggedCommandResult) + | undefined, + args: Array>, + currentInfo: { guild: Guild; channel: Channel }, + command: RepluggedCommand, +): Promise { + try { + const currentChannelId = currentInfo.channel.id; + const loadingMessage = messages.createBotMessage({ + channelId: currentChannelId, + content: "", + loggingName: "Replugged", + }); + + Object.assign(loadingMessage.author, { + username: "Replugged", + avatar: "replugged", + }); - public unregisterCommand(name: string): void { - if (this.commands.has(name)) { - this.dispatchEvent( - new CustomEvent("rpCommandRemoved", { detail: { command: this.commands.get(name) } }), - ); - this.commands.delete(name); + Object.assign(loadingMessage, { + flags: constants.MessageFlags.EPHEMERAL + constants.MessageFlags.LOADING, // adding loading too + state: "SENDING", // Keep it a little faded + interaction: { + // eslint-disable-next-line @typescript-eslint/naming-convention + name_localized: command.displayName, + name: command.name, + type: command.type, + id: command.id, + user: users.getCurrentUser(), + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + interaction_data: { + name: command.displayName, + }, + type: 20, + }); + messages.receiveMessage(currentChannelId, loadingMessage, true); + const interaction = new CommandInteraction({ options: args, ...currentInfo }); + const result = await cmdExecutor?.(interaction); + messages.dismissAutomatedMessage(loadingMessage); + + if ((!result?.result && !result?.embeds) || !currentChannelId) return; + + if (result.send) { + void messages.sendMessage(currentChannelId, { + content: result.result!, + invalidEmojis: [], + validNonShortcutEmojis: [], + tts: false, + }); } else { - commandsLogger.error(`Command “${name}” is not registered!`); + const botMessage = messages.createBotMessage({ + channelId: currentChannelId, + content: result.result || "", + embeds: result.embeds || [], + loggingName: "Replugged", + }); + + Object.assign(botMessage.author, { + username: "Replugged", + avatar: "replugged", + }); + + Object.assign(botMessage, { + interaction: { + // eslint-disable-next-line @typescript-eslint/naming-convention + name_localized: command.displayName, + name: command.name, + type: command.type, + id: command.id, + user: users.getCurrentUser(), + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + interaction_data: { + name: command.displayName, + }, + type: 20, + }); + messages.receiveMessage(currentChannelId, botMessage, true); } + } catch (error) { + logger.error(error); + const currentChannelId = currentInfo.channel.id; + const botMessage = messages.createBotMessage?.({ + channelId: currentChannelId, + content: i18n.Messages.REPLUGGED_COMMAND_ERROR_GENERIC, + embeds: [], + loggingName: "Replugged", + }); + if (!botMessage) return; + + Object.assign(botMessage.author, { + username: "Replugged", + avatar: "replugged", + }); + + Object.assign(botMessage, { + interaction: { + // eslint-disable-next-line @typescript-eslint/naming-convention + name_localized: command.displayName, + name: command.name, + type: command.type, + id: command.id, + user: users.getCurrentUser(), + }, + // eslint-disable-next-line @typescript-eslint/naming-convention + interaction_data: { + name: command.displayName, + }, + type: 20, + }); + + messages?.receiveMessage?.(currentChannelId, botMessage, true); } } -export default new CommandsAPI(); +export class CommandManager { + #section: RepluggedCommandSection; + #unregister: Array<() => void>; + public constructor() { + this.#section = defaultSection; + this.#section.type ??= 1; + this.#unregister = []; + } + + /** + * Code to register an slash command + * @param cmd Slash Command to be registered + * @returns An Callback to unregister the slash command + */ + public registerCommand(command: RepluggedCommand): () => void { + if (!commandAndSections.has(this.#section.id)) { + commandAndSections.set(this.#section.id, { + section: this.#section, + commands: new Map(), + }); + } + const currentSection = commandAndSections.get(this.#section.id); + command.applicationId = currentSection?.section.id; + command.displayName ??= command.name; + command.displayDescription ??= command.description; + command.type = 2; + command.id ??= command.name; + + command.execute ??= (args, currentInfo) => { + void executeCommand(command.executor, args ?? [], currentInfo ?? {}, command); + }; + + command.options?.map((option) => { + option.serverLocalizedName ??= option.displayName; + option.displayName ??= option.name; + option.displayDescription ??= option.description; + + return option; + }); + + currentSection?.commands.set(command.id, command as AnyRepluggedCommand); + + const uninject = (): void => { + void currentSection?.commands.delete(command.id!); + this.#unregister = this.#unregister.filter((u) => u !== uninject); + }; + this.#unregister.push(uninject); + return uninject; + } + /** + * Code to unregister all slash commands registered with this class + */ + public unregisterAllCommands(): void { + for (const unregister of this.#unregister) unregister?.(); + } +} diff --git a/src/renderer/coremods/commands/commands.ts b/src/renderer/coremods/commands/commands.ts new file mode 100644 index 000000000..214b27d61 --- /dev/null +++ b/src/renderer/coremods/commands/commands.ts @@ -0,0 +1,418 @@ +import { Messages } from "@common/i18n"; +import { Injector, plugins, themes } from "@replugged"; +import { ApplicationCommandOptionType } from "../../../types"; + +const injector = new Injector(); + +export function loadCommands(): void { + injector.utils.registerSlashCommand({ + name: Messages.REPLUGGED_COMMAND_ENABLE_NAME, + description: Messages.REPLUGGED_COMMAND_ENABLE_DESC, + options: [ + { + name: "addon", + displayName: Messages.REPLUGGED_COMMAND_ENABLE_OPTION_ADDON_NAME, + description: Messages.REPLUGGED_COMMAND_ADDONS_OPTION_ADDON_DESC, + type: ApplicationCommandOptionType.String, + required: true, + get choices() { + const choices = []; + + const disabledPlugins = Array.from(plugins.plugins.values()).filter((plugin) => + plugins.getDisabled().includes(plugin.manifest.id), + ); + + const disabledThemes = Array.from(themes.themes.values()).filter((theme) => + themes.getDisabled().includes(theme.manifest.id), + ); + + choices.push( + ...disabledPlugins + .map((plugin) => ({ + name: plugin.manifest.name, + displayName: `${Messages.REPLUGGED_PLUGIN}: ${plugin.manifest.name}`, + value: plugin.manifest.id, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + choices.push( + ...disabledThemes + .map((theme) => ({ + name: theme.manifest.name, + displayName: `${Messages.REPLUGGED_THEME}: ${theme.manifest.name}`, + value: theme.manifest.id, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + + return choices; + }, + }, + ], + executor: async (interaction) => { + try { + const addonId = interaction.getValue("addon"); + if (plugins.plugins.has(addonId)) { + await plugins.enable(addonId); + } else { + themes.enable(addonId); + } + return { + send: false, + embeds: [ + { + color: 0x1bbb1b, + title: Messages.REPLUGGED_COMMAND_SUCCESS_GENERIC, + description: Messages.REPLUGGED_COMMAND_ENABLE_MESSAGE_ENABLED.format({ + type: plugins.plugins.get(addonId) + ? Messages.REPLUGGED_PLUGIN + : Messages.REPLUGGED_THEME, + name: + plugins.plugins.get(addonId)?.manifest?.name ?? + themes.themes.get(addonId)?.manifest?.name, + }), + }, + ], + }; + } catch (err) { + return { + send: false, + embeds: [ + { + color: 0xdd2d2d, + title: Messages.REPLUGGED_COMMAND_ERROR_GENERIC, + description: err as string, + }, + ], + }; + } + }, + }); + injector.utils.registerSlashCommand({ + name: Messages.REPLUGGED_COMMAND_DISABLE_NAME, + description: Messages.REPLUGGED_COMMAND_DISABLE_DESC, + options: [ + { + name: "addon", + displayName: Messages.REPLUGGED_COMMAND_DISABLE_OPTION_ADDON_NAME, + description: Messages.REPLUGGED_COMMAND_DISABLE_OPTION_ADDON_DESC, + type: ApplicationCommandOptionType.String, + required: true, + get choices() { + const choices = []; + + const enabledPlugins = Array.from(plugins.plugins.values()).filter( + (plugin) => !plugins.getDisabled().includes(plugin.manifest.id), + ); + + const enabledThemes = Array.from(themes.themes.values()).filter( + (theme) => !themes.getDisabled().includes(theme.manifest.id), + ); + + choices.push( + ...enabledPlugins + .map((plugin) => ({ + name: plugin.manifest.name, + displayName: `${Messages.REPLUGGED_PLUGIN}: ${plugin.manifest.name}`, + value: plugin.manifest.id, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + choices.push( + ...enabledThemes + .map((theme) => ({ + name: theme.manifest.name, + displayName: `${Messages.REPLUGGED_THEME}: ${theme.manifest.name}`, + value: theme.manifest.id, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + + return choices; + }, + }, + ], + executor: async (interaction) => { + try { + const addonId = interaction.getValue("addon"); + if (plugins.plugins.has(addonId)) { + await plugins.disable(addonId); + } else { + themes.disable(addonId); + } + return { + send: false, + embeds: [ + { + color: 0x1bbb1b, + title: Messages.REPLUGGED_COMMAND_SUCCESS_GENERIC, + description: Messages.REPLUGGED_COMMAND_DISABLE_MESSAGE_ENABLED.format({ + type: plugins.plugins.get(addonId) + ? Messages.REPLUGGED_PLUGIN + : Messages.REPLUGGED_THEME, + name: + plugins.plugins.get(addonId)?.manifest?.name ?? + themes.themes.get(addonId)?.manifest?.name, + }), + }, + ], + }; + } catch (err) { + return { + send: false, + embeds: [ + { + color: 0xdd2d2d, + title: Messages.REPLUGGED_COMMAND_ERROR_GENERIC, + description: err as string, + }, + ], + }; + } + }, + }); + injector.utils.registerSlashCommand({ + name: Messages.REPLUGGED_COMMAND_RELOAD_NAME, + description: Messages.REPLUGGED_COMMAND_RELOAD_DESC, + options: [ + { + name: "addon", + displayName: Messages.REPLUGGED_COMMAND_RELOAD_OPTION_ADDON_NAME, + description: Messages.REPLUGGED_COMMAND_RELOAD_OPTION_ADDON_DESC, + type: ApplicationCommandOptionType.String, + required: true, + get choices() { + const choices = []; + + const enabledPlugins = Array.from(plugins.plugins.values()); + const enabledThemes = Array.from(themes.themes.values()); + + choices.push( + ...enabledPlugins + .map((plugin) => ({ + name: plugin.manifest.name, + displayName: `${Messages.REPLUGGED_PLUGIN}: ${plugin.manifest.name}`, + value: plugin.manifest.id, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + choices.push( + ...enabledThemes + .map((theme) => ({ + name: theme.manifest.name, + displayName: `${Messages.REPLUGGED_THEME}: ${theme.manifest.name}`, + value: theme.manifest.id, + })) + .sort((a, b) => a.name.localeCompare(b.name)), + ); + + return choices; + }, + }, + ], + executor: async (interaction) => { + try { + const addonId = interaction.getValue("addon"); + if (plugins.plugins.has(addonId)) { + await plugins.reload(addonId); + } else { + themes.reload(addonId); + } + return { + send: false, + embeds: [ + { + color: 0x1bbb1b, + title: Messages.REPLUGGED_COMMAND_SUCCESS_GENERIC, + description: Messages.REPLUGGED_COMMAND_RELOAD_MESSAGE_ENABLED.format({ + type: plugins.plugins.get(addonId) + ? Messages.REPLUGGED_PLUGIN + : Messages.REPLUGGED_THEME, + name: + plugins.plugins.get(addonId)?.manifest?.name ?? + themes.themes.get(addonId)?.manifest?.name, + }), + }, + ], + }; + } catch (err) { + return { + send: false, + embeds: [ + { + color: 0xdd2d2d, + title: Messages.REPLUGGED_COMMAND_ERROR_GENERIC, + description: err as string, + }, + ], + }; + } + }, + }); + injector.utils.registerSlashCommand({ + name: Messages.REPLUGGED_COMMAND_LIST_NAME, + description: Messages.REPLUGGED_COMMAND_LIST_DESC, + options: [ + { + name: "send", + displayName: Messages.REPLUGGED_COMMAND_LIST_OPTION_SEND_NAME, + description: Messages.REPLUGGED_COMMAND_LIST_OPTION_SEND_DESC, + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + { + name: "type", + displayName: Messages.REPLUGGED_COMMAND_LIST_OPTION_TYPE_NAME, + description: Messages.REPLUGGED_COMMAND_LIST_OPTION_TYPE_DESC, + type: ApplicationCommandOptionType.String, + required: true, + choices: [ + { + name: Messages.REPLUGGED_THEME, + displayName: Messages.REPLUGGED_COMMAND_LIST_OPTION_TYPE_CHOICE_THEME, + value: "theme", + }, + { + name: Messages.REPLUGGED_PLUGIN, + displayName: Messages.REPLUGGED_COMMAND_LIST_OPTION_TYPE_CHOICE_PLUGIN, + value: "plugin", + }, + ], + }, + { + name: "version", + displayName: Messages.REPLUGGED_COMMAND_LIST_OPTION_VERSION_NAME, + description: Messages.REPLUGGED_COMMAND_LIST_OPTION_VERSION_DESC, + type: ApplicationCommandOptionType.Boolean, + required: false, + }, + { + name: "status", + displayName: Messages.REPLUGGED_COMMAND_LIST_OPTION_STATUS_NAME, + description: Messages.REPLUGGED_COMMAND_LIST_OPTION_STATUS_DESC, + type: ApplicationCommandOptionType.String, + required: false, + choices: [ + { + name: Messages.REPLUGGED_COMMAND_LIST_OPTION_STATUS_CHOICE_ENABLED, + displayName: Messages.REPLUGGED_COMMAND_LIST_OPTION_STATUS_CHOICE_ENABLED, + value: "enabled", + }, + { + name: Messages.REPLUGGED_COMMAND_LIST_OPTION_STATUS_CHOICE_DISABLED, + displayName: Messages.REPLUGGED_COMMAND_LIST_OPTION_STATUS_CHOICE_DISABLED, + value: "disabled", + }, + { + name: Messages.REPLUGGED_COMMAND_LIST_OPTION_STATUS_CHOICE_BOTH, + displayName: Messages.REPLUGGED_COMMAND_LIST_OPTION_STATUS_CHOICE_BOTH, + value: "default", + }, + ], + }, + ], + executor: (interaction) => { + try { + const send = interaction.getValue("send", false); + const addonType = interaction.getValue("type"); + const version = interaction.getValue("version", true); + const listType = interaction.getValue("status", "default"); + + const generateListString = ( + items: Array<{ name: string; version: string }>, + typeName: string, + ): string => + `## ${typeName} (${items.length})${items.length ? ":\n•" : ""} ${items + .map((item) => (version ? `${item.name} (v${item.version})` : item.name)) + .join("\n• ")}`; + + switch (addonType) { + case "plugin": { + const allPlugins = Array.from(plugins.plugins.values()) + .map((p) => p.manifest) + .sort((a, b) => a.name.localeCompare(b.name)); + const enablePlugins = allPlugins.filter((p) => !plugins.getDisabled().includes(p.id)); + const disabledPlugins = allPlugins.filter((p) => plugins.getDisabled().includes(p.id)); + + const enabledString = generateListString( + enablePlugins, + Messages.REPLUGGED_COMMAND_LIST_HEADER_ENABLED.format({ + type: Messages.REPLUGGED_PLUGINS, + }), + ); + const disabledString = generateListString( + disabledPlugins, + Messages.REPLUGGED_COMMAND_LIST_HEADER_DISABLED.format({ + type: Messages.REPLUGGED_PLUGINS, + }), + ); + + const result = + listType === "enabled" + ? enabledString + : listType === "disabled" + ? disabledString + : `${enabledString}\n\n${disabledString}`; + + return { + send, + result, + }; + } + case "theme": { + const allThemes = Array.from(themes.themes.values()) + .map((t) => t.manifest) + .sort((a, b) => a.name.localeCompare(b.name)); + const enableThemes = allThemes.filter((t) => !plugins.getDisabled().includes(t.id)); + const disabledThemes = allThemes.filter((t) => plugins.getDisabled().includes(t.id)); + + const enabledString = generateListString( + enableThemes, + Messages.REPLUGGED_COMMAND_LIST_HEADER_ENABLED.format({ + type: Messages.REPLUGGED_THEMES, + }), + ); + const disabledString = generateListString( + disabledThemes, + Messages.REPLUGGED_COMMAND_LIST_HEADER_DISABLED.format({ + type: Messages.REPLUGGED_THEMES, + }), + ); + + const result = + listType === "enabled" + ? enabledString + : listType === "disabled" + ? disabledString + : `${enabledString}\n\n${disabledString}`; + + return { + send, + result, + }; + } + default: + return { + send: false, + result: Messages.REPLUGGED_COMMAND_LIST_ERROR_SPECIFY, + }; + } + } catch (err) { + return { + send: false, + embeds: [ + { + color: 0xdd2d2d, + title: Messages.REPLUGGED_COMMAND_ERROR_GENERIC, + description: err as string, + }, + ], + }; + } + }, + }); +} + +export function unloadCommands(): void { + injector.uninjectAll(); +} diff --git a/src/renderer/coremods/commands/index.ts b/src/renderer/coremods/commands/index.ts new file mode 100644 index 000000000..34f33b857 --- /dev/null +++ b/src/renderer/coremods/commands/index.ts @@ -0,0 +1,291 @@ +import type { AnyRepluggedCommand, RepluggedCommandSection } from "../../../types"; +import { Injector } from "../../modules/injector"; +import { Logger } from "../../modules/logger"; +import { + filters, + getExportsForProps, + getFunctionKeyBySource, + waitForModule, + waitForProps, +} from "../../modules/webpack"; + +import { commandAndSections, defaultSection } from "../../apis/commands"; +import { loadCommands, unloadCommands } from "./commands"; + +const logger = Logger.api("Commands"); +const injector = new Injector(); + +interface ApplicationCommandSearchStoreMod { + [key: string]: (...args: unknown[]) => { + sectionDescriptors: RepluggedCommandSection[]; + commands: AnyRepluggedCommand[]; + filteredSectionId: string; + activeSections: RepluggedCommandSection[]; + commandsByActiveSection: Array<{ + section: RepluggedCommandSection; + data: AnyRepluggedCommand[]; + }>; + }; +} + +interface ApplicationCommandSearchStore { + getChannelState: (...args: unknown[]) => { + applicationSections: RepluggedCommandSection[]; + applicationCommands: AnyRepluggedCommand[]; + }; + getApplicationSections: (...args: unknown[]) => RepluggedCommandSection[]; + useSearchManager: (...args: unknown[]) => unknown; + getQueryCommands: (...args: [string, string, string]) => AnyRepluggedCommand[]; +} + +async function injectRepluggedBotIcon(): Promise { + // Adds Avatar for replugged to default avatar to be used by system bot just like clyde + // Ain't removing it on stop because we have checks here + const DefaultAvatars = await waitForProps<{ + BOT_AVATARS: Record; + }>("BOT_AVATARS"); + if (DefaultAvatars?.BOT_AVATARS) { + DefaultAvatars.BOT_AVATARS.replugged = defaultSection.icon; + } else { + logger.error("Error while injecting custom icon for slash command replies."); + } +} + +async function injectRepluggedSectionIcon(): Promise { + // Patches the function which gets icon URL for slash command sections + // makes it return the custom url if it's our section + const AssetsUtils = await waitForProps<{ + getApplicationIconURL: (args: { id: string; icon: string }) => string; + }>("getApplicationIconURL"); + injector.after(AssetsUtils, "getApplicationIconURL", ([section], res) => + commandAndSections.has(section.id) ? commandAndSections.get(section.id)?.section.icon : res, + ); +} + +async function injectApplicationCommandSearchStore(): Promise { + // The module which contains the store + const ApplicationCommandSearchStoreMod = await waitForModule( + filters.bySource("ApplicationCommandSearchStore"), + ); + const storeModFnKey = getFunctionKeyBySource( + ApplicationCommandSearchStoreMod, + "APPLICATION_COMMAND_SEARCH_STORE_UPDATE", + ); + + // Base handler function for ApplicationCommandSearchStore which is ran to get the info in store + // commands are mainly added here + injector.after(ApplicationCommandSearchStoreMod, storeModFnKey!, (_, res) => { + const commandAndSectionsArray = Array.from(commandAndSections.values()).filter( + (commandAndSection) => commandAndSection.commands.size, + ); + if (!res || !commandAndSectionsArray.length) return res; + if ( + !Array.isArray(res.sectionDescriptors) || + !commandAndSectionsArray.every((commandAndSection) => + res.sectionDescriptors.some((section) => section.id === commandAndSection.section.id), + ) + ) { + const sectionsToAdd = commandAndSectionsArray + .map((commandAndSection) => commandAndSection.section) + .filter((section) => !res.sectionDescriptors.includes(section)); + if (res.sectionDescriptors.some?.((section) => section?.id === "-2")) { + res.sectionDescriptors.splice(1, 0, ...sectionsToAdd); + } else { + res.sectionDescriptors = Array.isArray(res.sectionDescriptors) + ? [...sectionsToAdd, ...res.sectionDescriptors] + : sectionsToAdd; + } + } + if ( + res.filteredSectionId === null || + commandAndSectionsArray.some( + (commandAndSection) => res.filteredSectionId === commandAndSection.section.id, + ) + ) { + const sectionsToAdd = commandAndSectionsArray + .map((commandAndSection) => commandAndSection.section) + .filter( + (section) => + (res.filteredSectionId == null || res.filteredSectionId === section.id) && + !res.activeSections.includes(section), + ); + if (res.activeSections.some?.((section) => section?.id === "-2")) { + res.activeSections.splice(1, 0, ...sectionsToAdd); + } else { + res.activeSections = Array.isArray(res.activeSections) + ? [...sectionsToAdd, ...res.activeSections] + : sectionsToAdd; + } + + const commandsBySectionToAdd = commandAndSectionsArray + .filter( + (commandAndSection) => + (res.filteredSectionId !== null + ? res.filteredSectionId === commandAndSection.section.id + : true) && + !res.commandsByActiveSection.some( + (activeCommandAndSection) => + activeCommandAndSection.section.id === commandAndSection.section.id, + ), + ) + .map((commandAndSection) => ({ + section: commandAndSection.section, + data: Array.from(commandAndSection.commands.values()), + })); + + if ( + res.commandsByActiveSection.some?.( + (activeCommandAndSections) => activeCommandAndSections?.section?.id === "-2", + ) + ) { + res.commandsByActiveSection.splice(1, 0, ...commandsBySectionToAdd); + } else { + res.commandsByActiveSection = Array.isArray(res.commandsByActiveSection) + ? [...commandsBySectionToAdd, ...res.commandsByActiveSection] + : commandsBySectionToAdd; + } + } + if ( + !Array.isArray(res.commands) || + commandAndSectionsArray.some((commandAndSection) => + Array.from(commandAndSection.commands.values()).some( + (command) => !res.commands.includes(command), + ), + ) + ) { + const commandsToAdd = commandAndSectionsArray + .map((commandAndSection) => Array.from(commandAndSection.commands.values())) + .flat(10); + res.commands = Array.isArray(res.commands) + ? [...res.commands.filter((command) => !commandsToAdd.includes(command)), ...commandsToAdd] + : commandsToAdd; + } + return res; + }); + + // The store itself + const ApplicationCommandSearchStore = getExportsForProps( + ApplicationCommandSearchStoreMod, + ["getApplicationSections", "getChannelState", "getQueryCommands"], + )!; + + // Channel state gets update with each character entered in text box and search so we patch this to keep our custom section + // even after updates happen + injector.after(ApplicationCommandSearchStore, "getChannelState", (_, res) => { + const commandAndSectionsArray = Array.from(commandAndSections.values()).filter( + (commandAndSection) => commandAndSection.commands.size, + ); + if (!res || !commandAndSectionsArray.length) return res; + if ( + !Array.isArray(res.applicationSections) || + !commandAndSectionsArray.every((commandAndSection) => + res.applicationSections?.some((section) => section.id === commandAndSection.section.id), + ) + ) { + const sectionsToAdd = commandAndSectionsArray.map( + (commandAndSection) => commandAndSection.section, + ); + res.applicationSections = Array.isArray(res.applicationSections) + ? [...sectionsToAdd, ...res.applicationSections] + : sectionsToAdd; + } + if ( + !Array.isArray(res.applicationCommands) || + commandAndSectionsArray.some((commandAndSection) => + Array.from(commandAndSection.commands.values()).some( + (command) => !res.applicationCommands.includes(command), + ), + ) + ) { + const commandsToAdd = commandAndSectionsArray + .map((commandAndSection) => Array.from(commandAndSection.commands.values())) + .flat(10); + res.applicationCommands = Array.isArray(res.applicationCommands) + ? [ + ...commandsToAdd, + ...res.applicationCommands.filter((command) => !commandsToAdd.includes(command)), + ] + : commandsToAdd; + } + return res; + }); + + // Makes sure if our custom section is included or not + // Add it if not + injector.after(ApplicationCommandSearchStore, "getApplicationSections", (_, res) => { + res ??= []; + const commandAndSectionsArray = Array.from(commandAndSections.values()).filter( + (commandAndSection) => commandAndSection.commands.size, + ); + if (!res || !commandAndSectionsArray.length) return; + if ( + !commandAndSectionsArray.every((commandAndSection) => + res.some((section) => section.id === commandAndSection.section.id), + ) + ) { + const sectionsToAdd = commandAndSectionsArray + .map((commandAndSection) => commandAndSection.section) + .filter((section) => res.some((existingSections) => section.id === existingSections.id)); + res = [...res, ...sectionsToAdd]; + } + return res; + }); + + // Slash command search patched to return our slash commands too + // only those which match tho + injector.after(ApplicationCommandSearchStore, "getQueryCommands", ([_, __, query], res) => { + if (!query || query.startsWith("/")) return res; + + res ??= []; + const commandsToAdd = Array.from(commandAndSections.values()) + .filter((commandAndSection) => commandAndSection.commands.size) + .map((commandAndSection) => Array.from(commandAndSection.commands.values())) + .flat(10); + for (const command of commandsToAdd) { + const exists = res.some((c) => c.id === command.id); + + if (exists || !command.name.includes(query)) { + continue; + } + + try { + res.unshift(command); + } catch { + res = [command, ...res]; + } + } + + return res; + }); +} + +async function injectProfileFetch(): Promise { + const mod = await waitForModule< + Record< + string, + ( + id: string, + avatar: string, + { guildId, channelId }: { guildId: string; channelId: string }, + ) => Promise + > + >(filters.bySource(".preloadUserBanner,"), { raw: true }); + const fnKey = getFunctionKeyBySource(mod.exports, ".apply(this"); + injector.instead(mod.exports, fnKey!, (args, res) => { + if (args[1] === defaultSection.icon) { + return; + } + return res(...args); + }); +} +export async function start(): Promise { + await injectRepluggedBotIcon(); + await injectRepluggedSectionIcon(); + await injectApplicationCommandSearchStore(); + await injectProfileFetch(); + loadCommands(); +} +export function stop(): void { + injector.uninjectAll(); + unloadCommands(); +} diff --git a/src/renderer/coremods/commands/plaintextPatches.ts b/src/renderer/coremods/commands/plaintextPatches.ts new file mode 100644 index 000000000..0b80980c4 --- /dev/null +++ b/src/renderer/coremods/commands/plaintextPatches.ts @@ -0,0 +1,14 @@ +import type { PlaintextPatch } from "src/types"; + +export default [ + { + //disables api request to find commands if its added by replugged + find: "filteredSectionId:null", + replacements: [ + { + match: /\w+\({applicationId:(\w+)}/, + replace: (suffix, id) => `${id} == "replugged"||${suffix}`, + }, + ], + }, +] as PlaintextPatch[]; diff --git a/src/renderer/managers/coremods.ts b/src/renderer/managers/coremods.ts index 39771892a..40f9e72f0 100644 --- a/src/renderer/managers/coremods.ts +++ b/src/renderer/managers/coremods.ts @@ -9,6 +9,7 @@ import { default as messagePopover } from "../coremods/messagePopover/plaintextP import { default as notices } from "../coremods/notices/plaintextPatches"; import { default as contextMenu } from "../coremods/contextMenu/plaintextPatches"; import { default as languagePlaintext } from "../coremods/language/plaintextPatches"; +import { default as commandsPlaintext } from "../coremods/commands/plaintextPatches"; import { Logger } from "../modules/logger"; const logger = Logger.api("Coremods"); @@ -31,6 +32,7 @@ export namespace coremods { export let language: Coremod; export let rpc: Coremod; export let watcher: Coremod; + export let commands: Coremod; export let welcome: Coremod; } @@ -53,6 +55,7 @@ export async function startAll(): Promise { coremods.language = await import("../coremods/language"); coremods.rpc = await import("../coremods/rpc"); coremods.watcher = await import("../coremods/watcher"); + coremods.commands = await import("../coremods/commands"); coremods.welcome = await import("../coremods/welcome"); await Promise.all( Object.entries(coremods).map(async ([name, mod]) => { @@ -79,5 +82,6 @@ export function runPlaintextPatches(): void { notices, contextMenu, languagePlaintext, + commandsPlaintext, ].forEach(patchPlaintext); } diff --git a/src/renderer/modules/common/constants.ts b/src/renderer/modules/common/constants.ts index cbb28e3aa..99d9f286d 100644 --- a/src/renderer/modules/common/constants.ts +++ b/src/renderer/modules/common/constants.ts @@ -50,6 +50,10 @@ export const GuildFeatures = getExportsForProps>(Constant "VERIFIED", "ANIMATED_BANNER", ])!; +export const MessageFlags = getExportsForProps>(Constants, [ + "EPHEMERAL", + "LOADING", +])!; export const Routes = getExportsForProps>(Constants, ["INDEX", "LOGIN"])!; export const UserFlags = getExportsForProps>(Constants, [ "STAFF", diff --git a/src/renderer/modules/common/i18n.ts b/src/renderer/modules/common/i18n.ts index 93bb8e682..00a004de9 100644 --- a/src/renderer/modules/common/i18n.ts +++ b/src/renderer/modules/common/i18n.ts @@ -181,7 +181,7 @@ interface IntlMessageObject { plainFormat: (values?: string | IntlMessageValues) => string; } -type Message = string & IntlMessageObject; +export type Message = string & IntlMessageObject; type Messages = Record; export interface I18n extends EventEmitter { diff --git a/src/renderer/modules/common/messages.ts b/src/renderer/modules/common/messages.ts index c706bd05d..0030ce04a 100644 --- a/src/renderer/modules/common/messages.ts +++ b/src/renderer/modules/common/messages.ts @@ -1,8 +1,8 @@ /* eslint-disable @typescript-eslint/naming-convention */ -import { waitForProps } from "../webpack"; - -import type { Channel, Message, MessageAttachment } from "discord-types/general"; +import type { Channel, Message, MessageAttachment, User } from "discord-types/general"; import { virtualMerge } from "src/renderer/util"; +import type { APIEmbed } from "src/types"; +import { filters, getFunctionBySource, waitForModule, waitForProps } from "../webpack"; export enum ActivityActionTypes { JOIN = 1, @@ -414,14 +414,58 @@ export interface MessageActions { _tryFetchMessagesCached: (options: FetchMessagesCachedOptions) => boolean; } -export type Messages = PartialMessageStore & MessageActions; +interface CreateBotMessageOptions { + channelId: string; + content: string; + embeds?: APIEmbed[]; + loggingName?: string; +} + +interface CreateMessageOptions { + channelId: string; + content: string; + tts?: boolean; + type?: number; + messageReference?: MessageReference; + allowedMentions?: AllowedMentions; + author: User; + flags?: number; + nonce?: string; +} + +interface UserServer { + id: string; + username: string; + avatar: string | null; + discriminator: string; + bot: boolean | undefined; + global_name: string | null; +} + +interface MessageUtils { + createBotMessage: (options: CreateBotMessageOptions) => Message; + createMessage: (options: CreateMessageOptions) => Message; + createNonce: () => string; + userRecordToServer: (user: User) => UserServer; +} const MessageStore = await waitForProps("getMessage", "getMessages"); +const MessageUtilsMod = await waitForModule(filters.bySource('username:"Clyde"')); +const MessageUtils = { + createBotMessage: getFunctionBySource(MessageUtilsMod, 'username:"Clyde"'), + createMessage: getFunctionBySource(MessageUtilsMod, "createMessage"), + createNonce: getFunctionBySource(MessageUtilsMod, "fromTimestamp"), + userRecordToServer: getFunctionBySource(MessageUtilsMod, "global_name:"), +} as MessageUtils; + +export type Messages = PartialMessageStore & MessageActions & MessageUtils; + export default virtualMerge( await waitForProps("sendMessage", "editMessage", "deleteMessage"), { getMessage: MessageStore.getMessage, getMessages: MessageStore.getMessages, }, + MessageUtils, ); diff --git a/src/renderer/modules/injector.ts b/src/renderer/modules/injector.ts index f01aa1524..3acc310bb 100644 --- a/src/renderer/modules/injector.ts +++ b/src/renderer/modules/injector.ts @@ -1,9 +1,11 @@ -import type { ObjectExports } from "../../types/webpack"; -import type { AnyFunction } from "../../types/util"; -import type { GetButtonItem } from "../../types/coremods/message"; +import type { CommandOptions, RepluggedCommand } from "src/types"; import type { ContextMenuTypes, GetContextItem } from "../../types/coremods/contextMenu"; -import { addButton } from "../coremods/messagePopover"; +import type { GetButtonItem } from "../../types/coremods/message"; +import type { AnyFunction } from "../../types/util"; +import type { ObjectExports } from "../../types/webpack"; +import { CommandManager } from "../apis/commands"; import { addContextMenuItem } from "../coremods/contextMenu"; +import { addButton } from "../coremods/messagePopover"; enum InjectionTypes { Before, @@ -217,7 +219,7 @@ function after< */ export class Injector { #uninjectors = new Set<() => void>(); - + #slashCommandManager = new CommandManager(); /** * Run code before a native module * @param obj Module to inject to @@ -361,6 +363,37 @@ export class Injector { this.#uninjectors.add(uninjector); return uninjector; }, + + /** + * A utility function to add a custom slash command. + * @param cmd The slash command to add to register + * @returns A callback to de-register the command + * + * @example + * ``` + * import { Injector, components, types } from "replugged"; + * + * const injector = new Injector(); + * + * export function start() { + * injector.utils.registerSlashCommand({ + * name: "use", + * description: "a command meant to be used", + * usage: "/use", + * executor: (interaction) => {}, + * }) + * } + * + * export function stop() { + * injector.uninjectAll(); + * } + * ``` + */ + registerSlashCommand: (cmd: RepluggedCommand) => { + const uninjector = this.#slashCommandManager.registerCommand(cmd); + this.#uninjectors.add(uninjector); + return uninjector; + }, }; /** diff --git a/src/renderer/replugged.ts b/src/renderer/replugged.ts index baa5ee924..534e0d7ec 100644 --- a/src/renderer/replugged.ts +++ b/src/renderer/replugged.ts @@ -22,7 +22,6 @@ export * as components from "./modules/components"; export * as i18n from "./modules/i18n"; export { default as notices } from "./apis/notices"; -export { default as commands } from "./apis/commands"; export * as settings from "./apis/settings"; /** diff --git a/src/types/coremods/commands.ts b/src/types/coremods/commands.ts new file mode 100644 index 000000000..47ccf4035 --- /dev/null +++ b/src/types/coremods/commands.ts @@ -0,0 +1,97 @@ +import type { Channel, Guild } from "discord-types/general"; +import type { ValueOf } from "type-fest"; +import { CommandInteraction } from "../../renderer/apis/commands"; +import type { + APIEmbed, + CommandChoices, + CommandOptionReturn, + CommandOptions, + StringOptions, +} from "../discord"; +import { ApplicationCommandOptionType } from "../discord"; + +interface OptionTypeMapping { + [ApplicationCommandOptionType.String]: string; + [ApplicationCommandOptionType.Integer]: number; + [ApplicationCommandOptionType.Boolean]: boolean; + [ApplicationCommandOptionType.User]: string; // its user id + [ApplicationCommandOptionType.Channel]: string; // its channel id + [ApplicationCommandOptionType.Role]: string; // its role id + [ApplicationCommandOptionType.Mentionable]: string; // id of whatever can be mentioned. usually channel/user/role + [ApplicationCommandOptionType.Number]: number; + [ApplicationCommandOptionType.Attachment]: { uploadedFilename: string; file: File }; +} + +type GetConditionallyOptional = Required extends true + ? T + : T | undefined; + +type GetType = GetConditionallyOptional< + T extends StringOptions + ? T["choices"] extends CommandChoices + ? T["choices"][number]["value"] + : OptionTypeMapping[T["type"]] + : OptionTypeMapping[T["type"]], + T["required"] +>; + +export type GetCommandOption = Extract< + T, + { name: K } +>; + +export type GetCommandOptions = ValueOf<{ + [K in T["name"]]: { + focused?: boolean; + name: K; + type: T["type"]; + value: GetType>; + }; +}>; + +export type GetValueType = undefined extends T["value"] + ? Exclude | D + : T["value"]; + +export interface InexecutableRepluggedCommand { + applicationId?: string; + type?: number; + id?: string; + name: string; + displayName?: string; + description: string; + displayDescription?: string; + usage?: string; + options?: readonly T[]; +} + +export type RepluggedCommand = InexecutableRepluggedCommand & + ( + | { + executor: ( + interaction: CommandInteraction>, + ) => Promise | RepluggedCommandResult; + execute?: never; + } + | { + execute: ( + args: Array>, + currentInfo: { channel: Channel; guild: Guild }, + ) => Promise | void; + executor?: never; + } + ); + +export type AnyRepluggedCommand = RepluggedCommand; + +export interface RepluggedCommandResult { + send: boolean; + result?: string; + embeds?: APIEmbed[]; +} +export interface RepluggedCommandSection { + id: string; + name: string; + type?: 1; + icon: string; +} diff --git a/src/types/discord.ts b/src/types/discord.ts index 791c24649..0fe4ae8e0 100644 --- a/src/types/discord.ts +++ b/src/types/discord.ts @@ -1,26 +1,144 @@ -export interface CommandOptions { - type: number; +export enum ApplicationCommandOptionType { + String = 3, + Integer = 4, + Boolean = 5, + User = 6, + Channel = 7, + Role = 8, + Mentionable = 9, + Number = 10, + Attachment = 11, +} + +interface BaseCommandOptions { + type: T; name: string; displayName?: string; description: string; displayDescription?: string; + serverLocalizedName?: string; required?: boolean; - choices?: Array<{ - name: string; - values: string | number; - }>; - options?: CommandOptions[]; +} + +export interface CommandChoices { + name: string; + displayName: string; + value: string | number; +} + +export interface CommandOptionAutocompleteAndChoices { + autocomplete?: boolean; + choices?: CommandChoices[]; + focused?: boolean; +} + +export interface StringOptions + extends CommandOptionAutocompleteAndChoices, + BaseCommandOptions { + /* eslint-disable @typescript-eslint/naming-convention */ + min_length?: number; + max_length?: number; + /* eslint-enable @typescript-eslint/naming-convention */ +} + +export interface NumberOptions + extends CommandOptionAutocompleteAndChoices, + BaseCommandOptions { /* eslint-disable @typescript-eslint/naming-convention */ - channel_types?: number[]; min_value?: number; max_value?: number; /* eslint-enable @typescript-eslint/naming-convention */ - autocomplete?: boolean; } +export interface ChannelOptions extends BaseCommandOptions { + /* eslint-disable @typescript-eslint/naming-convention */ + channel_types?: readonly number[]; +} + +export interface OtherCommandOptions + extends BaseCommandOptions< + | ApplicationCommandOptionType.Attachment + | ApplicationCommandOptionType.Boolean + | ApplicationCommandOptionType.Mentionable + | ApplicationCommandOptionType.Role + | ApplicationCommandOptionType.User + > {} + +export interface CommandOptionReturn { + name: string; + type: ApplicationCommandOptionType; + value: T; +} + +export type CommandOptions = StringOptions | NumberOptions | ChannelOptions | OtherCommandOptions; + export interface ConnectedAccount { type: string; name: string; id: string; verified: boolean; } + +export enum MessageEmbedTypes { + IMAGE = "image", + VIDEO = "video", + LINK = "link", + ARTICLE = "article", + TWEET = "tweet", + RICH = "rich", + GIFV = "gifv", + APPLICATION_NEWS = "application_news", + AUTO_MODERATION_MESSAGE = "auto_moderation_message", + AUTO_MODERATION_NOTIFICATION = "auto_moderation_notification", + TEXT = "text", + POST_PREVIEW = "post_preview", + GIFT = "gift", + SAFETY_POLICY_NOTICE = "safety_policy_notice", +} + +export interface APIEmbed { + title?: string; + type?: MessageEmbedTypes; + description?: string; + url?: string; + timestamp?: string; + color?: number; + footer?: { + text: string; + icon_url?: string; + proxy_icon_url?: string; + }; + image?: { + url: string; + proxy_url?: string; + height?: number; + width?: number; + }; + thumbnail?: { + url: string; + proxy_url?: string; + width?: number; + height?: number; + }; + video?: { + url?: string; + proxy_url?: string; + height?: number; + width?: number; + }; + provider?: { + name?: string; + url?: string; + }; + author?: { + name: string; + url?: string; + icon_url?: string; + proxy_icon_url?: string; + }; + fields?: Array<{ + name: string; + value: string; + inline?: boolean; + }>; +} diff --git a/src/types/index.ts b/src/types/index.ts index 37ea7a987..022d3c3e4 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -1,5 +1,5 @@ import type { WebContents } from "electron"; -import type { CommandOptions, ConnectedAccount } from "./discord"; +import type { ConnectedAccount } from "./discord"; import type { PluginManifest, ThemeManifest } from "./addon"; export type RepluggedWebContents = WebContents & { @@ -47,14 +47,6 @@ export interface RepluggedAnnouncement { }; } -export interface RepluggedCommand { - name: string; - description: string; - usage: string; - executor: (args: unknown) => void; - options: CommandOptions; -} - export interface RepluggedConnection { type: string; name: string; @@ -95,3 +87,4 @@ export * from "./installer"; export * from "./coremods/message"; export * from "./coremods/settings"; export * from "./coremods/contextMenu"; +export * from "./coremods/commands"; diff --git a/src/types/util.ts b/src/types/util.ts index 6b9265990..c3629d001 100644 --- a/src/types/util.ts +++ b/src/types/util.ts @@ -2,9 +2,3 @@ export type AnyFunction = (...args: any[]) => unknown; export type UnknownFunction = (...args: unknown[]) => unknown; export type ObjectKey = { [K in keyof O]: O[K] extends T ? K : never }[keyof O & string]; - -export type ReactComponent

= React.ComponentType< - React.PropsWithChildren

> ->; - -export type ObjectWithProps

= Record;