diff --git a/src/api/Commands.ts b/src/api/Commands.ts new file mode 100644 index 000000000..3a3e88c0e --- /dev/null +++ b/src/api/Commands.ts @@ -0,0 +1,157 @@ +import { Channel, Guild } from "discord-types/general"; +import { waitFor } from '../webpack'; + +export function _init(cmds: Command[]) { + try { + BUILT_IN = cmds; + OptionalMessageOption = cmds.find(c => c.name === "shrug")!.options![0]; + RequiredMessageOption = cmds.find(c => c.name === "me")!.options![0]; + } catch (e) { + console.error("Failed to load CommandsApi"); + } + return cmds; +} + +export let BUILT_IN: Command[]; +export const commands = {} as Record; + +// hack for plugins being evaluated before we can grab these from webpack +const OptPlaceholder = Symbol("OptionalMessageOption") as any as Option; +const ReqPlaceholder = Symbol("RequiredMessageOption") as any as Option; +/** + * Optional message option named "message" you can use in commands. + * Used in "tableflip" or "shrug" + * @see {@link RequiredMessageOption} + */ +export let OptionalMessageOption: Option = OptPlaceholder; +/** + * Required message option named "message" you can use in commands. + * Used in "me" + * @see {@link OptionalMessageOption} + */ +export let RequiredMessageOption: Option = ReqPlaceholder; + +let SnowflakeUtils: any; +waitFor("fromTimestamp", m => SnowflakeUtils = m); + +export function generateId() { + return `-${SnowflakeUtils.fromTimestamp(Date.now())}`; +} + +/** + * Get the value of an option by name + * @param args Arguments array (first argument passed to execute) + * @param name Name of the argument + * @param fallbackValue Fallback value in case this option wasn't passed + * @returns Value + */ +export function findOption(args: Argument[], name: string, fallbackValue?: T): T extends undefined ? T : string { + return (args.find(a => a.name === name)?.value || fallbackValue) as any; +} + +function modifyOpt(opt: Option | Command) { + opt.displayName ||= opt.name; + opt.displayDescription ||= opt.description; + opt.options?.forEach((opt, i, opts) => { + // See comment above Placeholders + if (opt === OptPlaceholder) opts[i] = OptionalMessageOption; + else if (opt === ReqPlaceholder) opts[i] = RequiredMessageOption; + modifyOpt(opts[i]); + }); +} + +export function registerCommand(command: Command, plugin: string) { + if (BUILT_IN.some(c => c.name === command.name)) + throw new Error(`Command '${command.name}' already exists.`); + + command.id ||= generateId(); + command.applicationId ||= "-1"; // BUILT_IN; + command.type ||= ApplicationCommandType.CHAT_INPUT; + command.inputType ||= ApplicationCommandInputType.BUILT_IN_TEXT; + command.plugin ||= plugin; + + modifyOpt(command); + commands[command.name] = command; + BUILT_IN.push(command); +} + +export function unregisterCommand(name: string) { + const idx = BUILT_IN.findIndex(c => c.name === name); + if (idx === -1) + return false; + + BUILT_IN.splice(idx, 1); + delete commands[name]; +} + +export interface CommandContext { + channel: Channel; + guild?: Guild; +} + +export enum ApplicationCommandOptionType { + SUB_COMMAND = 1, + SUB_COMMAND_GROUP = 2, + STRING = 3, + INTEGER = 4, + BOOLEAN = 5, + USER = 6, + CHANNEL = 7, + ROLE = 8, + MENTIONABLE = 9, + NUMBER = 10, + ATTACHMENT = 11, +} + +export enum ApplicationCommandInputType { + BUILT_IN = 0, + BUILT_IN_TEXT = 1, + BUILT_IN_INTEGRATION = 2, + BOT = 3, + PLACEHOLDER = 4, +} + +export interface Option { + name: string; + displayName?: string; + type: ApplicationCommandOptionType; + description: string; + displayDescription?: string; + required?: boolean; + options?: Option[]; +} + +export enum ApplicationCommandType { + CHAT_INPUT = 1, + USER = 2, + MESSAGE = 3, +} + +export interface CommandReturnValue { + content: string; +} + +export interface Argument { + type: ApplicationCommandOptionType; + name: string; + value: string; + focused: undefined; +} + +export interface Command { + id?: string; + applicationId?: string; + type?: ApplicationCommandType; + inputType?: ApplicationCommandInputType; + plugin?: string; + + name: string; + displayName?: string; + description: string; + displayDescription?: string; + + options?: Option[]; + predicate?(ctx: CommandContext): boolean; + + execute(args: Argument[], ctx: CommandContext): CommandReturnValue | void; +} diff --git a/src/api/index.ts b/src/api/index.ts index 7d39b9571..7c379f9be 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -1,2 +1,3 @@ export * as MessageEvents from "./MessageEvents"; export * as Notices from "./Notices"; +export * as Commands from "./Commands"; diff --git a/src/components/Settings.tsx b/src/components/Settings.tsx index 4e0196fbc..0678745e1 100644 --- a/src/components/Settings.tsx +++ b/src/components/Settings.tsx @@ -129,23 +129,25 @@ export default ErrorBoundary.wrap(function Settings() { value={settings.plugins[p.name].enabled || p.required || dependency} onChange={v => { settings.plugins[p.name].enabled = v; + let needsRestart = Boolean(p.patches?.length); if (v) { p.dependencies?.forEach(d => { + const dep = Plugins[d]; + needsRestart ||= Boolean(dep.patches?.length && !settings.plugins[d].enabled); settings.plugins[d].enabled = true; - if (!Plugins[d].started && !stopPlugin) { - settings.plugins[p.name].enabled = false; + if (!needsRestart && !dep.started && !startPlugin(dep)) { showErrorToast(`Failed to start dependency ${d}. Check the console for more info.`); } }); - if (!p.started && !startPlugin(p)) { + if (!needsRestart && !p.started && !startPlugin(p)) { showErrorToast(`Failed to start plugin ${p.name}. Check the console for more info.`); } } else { - if (p.started && !stopPlugin(p)) { + if ((p.started || !p.start && p.commands?.length) && !stopPlugin(p)) { showErrorToast(`Failed to stop plugin ${p.name}. Check the console for more info.`); } } - if (p.patches) changes.handleChange(p.name); + if (needsRestart) changes.handleChange(p.name); }} note={p.description} tooltipNote={ diff --git a/src/plugins/apiCommands.ts b/src/plugins/apiCommands.ts new file mode 100644 index 000000000..7c02dd988 --- /dev/null +++ b/src/plugins/apiCommands.ts @@ -0,0 +1,22 @@ +import definePlugin from "../utils/types"; +import { Devs } from "../utils/constants"; + +export default definePlugin({ + name: "CommandsAPI", + authors: [Devs.Arjix], + description: "Api required by anything that uses commands", + patches: [ + { + find: `"giphy","tenor"`, + replacement: [ + { + // Matches BUILT_IN_COMMANDS. This is not exported so this is + // the only way. _init() just returns the same object to make the + // patch simpler, the resulting code is x=Vencord.Api.Commands._init(y).filter(...) + match: /(?<=\w=)(\w)(\.filter\(.{0,30}giphy)/, + replace: "Vencord.Api.Commands._init($1)$2", + } + ], + } + ], +}); diff --git a/src/plugins/index.ts b/src/plugins/index.ts index e03c588d2..44f1e83ff 100644 --- a/src/plugins/index.ts +++ b/src/plugins/index.ts @@ -1,4 +1,5 @@ import Plugins from "plugins"; +import { registerCommand, unregisterCommand } from "../api/Commands"; import { Settings } from "../api/settings"; import Logger from "../utils/logger"; import { Patch, Plugin } from "../utils/types"; @@ -17,44 +18,70 @@ for (const plugin of Object.values(Plugins)) if (plugin.patches && Settings.plug } export function startAllPlugins() { - for (const plugin in Plugins) if (Settings.plugins[plugin].enabled) { - startPlugin(Plugins[plugin]); + for (const name in Plugins) if (Settings.plugins[name].enabled) { + startPlugin(Plugins[name]); } } export function startPlugin(p: Plugin) { - if (!p.start) return true; - - logger.info("Starting plugin", p.name); - if (p.started) { - logger.warn(`${p.name} already started`); - return false; + if (p.start) { + logger.info("Starting plugin", p.name); + if (p.started) { + logger.warn(`${p.name} already started`); + return false; + } + try { + p.start(); + p.started = true; + } catch (e) { + logger.error(`Failed to start ${p.name}\n`, e); + return false; + } } - try { - p.start(); - p.started = true; - return true; - } catch (err: any) { - logger.error(`Failed to start ${p.name}\n`, err); - return false; + if (p.commands?.length) { + logger.info("Registering commands of plugin", p.name); + for (const cmd of p.commands) { + try { + registerCommand(cmd, p.name); + } catch (e) { + logger.error(`Failed to register command ${cmd.name}\n`, e); + return false; + } + } + } + + return true; } export function stopPlugin(p: Plugin) { - if (!p.stop) return true; + if (p.stop) { + logger.info("Stopping plugin", p.name); + if (!p.started) { + logger.warn(`${p.name} already stopped`); + return false; + } + try { + p.stop(); + p.started = false; + } catch (e) { + logger.error(`Failed to stop ${p.name}\n`, e); + return false; + } + } - logger.info("Stopping plugin", p.name); - if (!p.started) { - logger.warn(`${p.name} already stopped / never started`); - return false; - } - try { - p.stop(); - p.started = false; - return true; - } catch (err: any) { - logger.error(`Failed to stop ${p.name}\n`, err); - return false; + if (p.commands?.length) { + logger.info("Unregistering commands of plugin", p.name); + for (const cmd of p.commands) { + try { + unregisterCommand(cmd.name); + } catch (e) { + logger.error(`Failed to unregister command ${cmd.name}\n`, e); + return false; + } + } } + + return true; } diff --git a/src/plugins/lenny.ts b/src/plugins/lenny.ts new file mode 100644 index 000000000..901febc95 --- /dev/null +++ b/src/plugins/lenny.ts @@ -0,0 +1,20 @@ +import definePlugin from "../utils/types"; +import { Devs } from "../utils/constants"; +import { findOption, OptionalMessageOption } from "../api/Commands"; + +export default definePlugin({ + name: "lenny", + description: "( ͡° ͜ʖ ͡°)", + authors: [Devs.Arjix], + dependencies: ["CommandsAPI"], + commands: [ + { + name: "lenny", + description: "Sends a lenny face", + options: [OptionalMessageOption], + execute: (opts) => ({ + content: findOption(opts, "message", "") + " ( ͡° ͜ʖ ͡°)" + }), + }, + ] +}); diff --git a/src/plugins/nitroBypass.ts b/src/plugins/nitroBypass.ts index 4a0ed44c7..d96ff0d8e 100644 --- a/src/plugins/nitroBypass.ts +++ b/src/plugins/nitroBypass.ts @@ -61,7 +61,7 @@ export default definePlugin({ const emojiString = `<${emoji.animated ? 'a' : ''}:${emoji.originalName || emoji.name}:${emoji.id}>`; const url = emoji.url.replace(/\?size=[0-9]+/, `?size=48`); messageObj.content = messageObj.content.replace(emojiString, (match, offset, origStr) => { - return `${getWordBoundary(origStr, offset-1)}${url}${getWordBoundary(origStr, offset+match.length)}`; + return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`; }); } }); @@ -76,7 +76,7 @@ export default definePlugin({ const url = emoji.url.replace(/\?size=[0-9]+/, `?size=48`); messageObj.content = messageObj.content.replace(emojiStr, (match, offset, origStr) => { - return `${getWordBoundary(origStr, offset-1)}${url}${getWordBoundary(origStr, offset+match.length)}`; + return `${getWordBoundary(origStr, offset - 1)}${url}${getWordBoundary(origStr, offset + match.length)}`; }); } }); diff --git a/src/utils/types.ts b/src/utils/types.ts index 1c6361360..6d4b78e95 100644 --- a/src/utils/types.ts +++ b/src/utils/types.ts @@ -1,3 +1,5 @@ +import { Command } from "../api/Commands"; + // exists to export default definePlugin({...}) export default function definePlugin(p: PluginDef & Record) { return p; @@ -31,6 +33,7 @@ interface PluginDef { start?(): void; stop?(): void; patches?: Omit[]; + commands?: Command[]; dependencies?: string[], required?: boolean; /**