diff --git a/README.md b/README.md index 0f84436..26cfc13 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,13 @@ yarn add @sern/handler ```sh pnpm add @sern/handler ``` - +## Why? +- Most handlers don't support discord.js 14.7+ +- Customizable commands +- Plug and play or customize to your liking +- Embraces reactive programming for consistent and reliable backend +- Customizable logger, error handling, and more +- Active development and growing [community](https://sern.dev/discord) ## 👀 Quick Look * Support for discord.js v14 and all interactions @@ -105,7 +111,7 @@ It is **highly encouraged** to use the [command line interface](https://github.c ## 👋 Contribute -- Read our contribution [guidelines](./.github/CONTRIBUTING.md) carefully +- Read our contribution [guidelines](https://github.com/sern-handler/handler/blob/main/.github/CONTRIBUTING.md) carefully - Pull up on [issues](https://github.com/sern-handler/handler/issues) and report bugs - All kinds of contributions are welcomed. diff --git a/package.json b/package.json index 387fa67..7da10e3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@sern/handler", "packageManager": "pnpm@7.25.0", - "version": "2.1.1", + "version": "2.5.0", "description": "A customizable, batteries-included, powerful discord.js framework to automate and streamline bot development.", "main": "dist/cjs/index.cjs", "module": "dist/esm/index.mjs", @@ -35,17 +35,17 @@ "dependencies": { "iti": "^0.6.0", "rxjs": "^7.5.6", - "ts-pattern": "^4.0.2", + "ts-pattern": "^4.0.6", "ts-results-es": "^3.5.0" }, "devDependencies": { "@typescript-eslint/eslint-plugin": "5.47.1", "@typescript-eslint/parser": "5.48.0", + "discord.js": ">= ^14.7.x", "eslint": "8.30.0", "prettier": "2.8.3", "tsup": "^6.1.3", - "typescript": "4.9.4", - "discord.js": ">= ^14.7.x" + "typescript": "4.9.4" }, "repository": { "type": "git", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 044952a..8dd9b11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,7 +8,7 @@ specifiers: iti: ^0.6.0 prettier: 2.8.3 rxjs: ^7.5.6 - ts-pattern: ^4.0.2 + ts-pattern: ^4.0.6 ts-results-es: ^3.5.0 tsup: ^6.1.3 typescript: 4.9.4 diff --git a/src/handler/contracts/errorHandling.ts b/src/handler/contracts/errorHandling.ts index ea92ff0..9169c5b 100644 --- a/src/handler/contracts/errorHandling.ts +++ b/src/handler/contracts/errorHandling.ts @@ -1,6 +1,5 @@ import type { Observable } from 'rxjs'; import type { Logging } from './logging'; -import { useContainerRaw } from '../dependencies/provider'; import util from 'util'; export interface ErrorHandling { /** @@ -36,11 +35,6 @@ export function handleError(crashHandler: ErrorHandling, logging?: Logging) { // This is done to fit the ErrorHandling contract const err = pload instanceof Error ? pload : Error(util.format(pload)); if (crashHandler.keepAlive == 0) { - useContainerRaw() - ?.disposeAll() - .then(() => { - logging?.info({ message: 'Cleaning container and crashing' }); - }); crashHandler.crash(err); } //formatted payload diff --git a/src/handler/contracts/moduleManager.ts b/src/handler/contracts/moduleManager.ts index dc47abd..2c74937 100644 --- a/src/handler/contracts/moduleManager.ts +++ b/src/handler/contracts/moduleManager.ts @@ -1,17 +1,19 @@ import type { CommandModuleDefs } from '../../types/module'; -import type { CommandType } from '../structures/enums'; -import type { ModuleStore } from '../structures/moduleStore'; +import type { CommandType, ModuleStore } from '../structures'; +import type { Processed } from '../../types/handler'; export interface ModuleManager { get( - strat: (ms: ModuleStore) => CommandModuleDefs[T] | undefined, + strat: (ms: ModuleStore) => Processed | undefined, ): CommandModuleDefs[T] | undefined; set(strat: (ms: ModuleStore) => void): void; } export class DefaultModuleManager implements ModuleManager { constructor(private moduleStore: ModuleStore) {} - get(strat: (ms: ModuleStore) => CommandModuleDefs[T] | undefined) { + get( + strat: (ms: ModuleStore) => Processed | undefined, + ) { return strat(this.moduleStore); } diff --git a/src/handler/dependencies/index.ts b/src/handler/dependencies/index.ts index bf85ade..0d8a416 100644 --- a/src/handler/dependencies/index.ts +++ b/src/handler/dependencies/index.ts @@ -1 +1,2 @@ -export { useContainer } from './provider'; +export { single, transient, many } from './lifetimeFunctions'; +export { useContainerRaw } from './provider'; \ No newline at end of file diff --git a/src/handler/dependencies/lifetimeFunctions.ts b/src/handler/dependencies/lifetimeFunctions.ts new file mode 100644 index 0000000..f608030 --- /dev/null +++ b/src/handler/dependencies/lifetimeFunctions.ts @@ -0,0 +1,50 @@ +import { _const } from '../utilities/functions'; + +type NotFunction = string | number | boolean | null | undefined | bigint | + readonly any[] | { apply?: never, [k: string]: any } | + { call?: never, [k: string]: any }; + +/** + * @deprecated + * @param cb + */ +export function single(cb: T) : () => T; +/** + * New signature + * @param cb + */ +export function single unknown>(cb: T) : T; +/** + * Please note that on intellij, the deprecation is for all signatures, which is unintended behavior (and + * very annoying). + * For future versions, ensure that single is being passed as a **callback!!** + * @param cb + */ +export function single(cb: T) { + if(typeof cb === 'function') return cb; + return () => cb; +} +/** + * @deprecated + * @param cb + * Deprecated signature + */ +export function transient(cb: T) : () => () => T +export function transient () => unknown>(cb: T) : T; +/** + * Following iti's singleton and transient implementation, + * use transient if you want a new dependency every time your container getter is called + * @param cb + */ +export function transient(cb: (() => () => T) | T) { + if(typeof cb !== 'function') return () => () => cb; + return cb; +} + +/** + * @deprecated + * @param value + * Please use the transient function instead + */ +// prettier-ignore +export const many = (value: T) => () => _const(value); diff --git a/src/handler/dependencies/provider.ts b/src/handler/dependencies/provider.ts index ae438b7..d199a80 100644 --- a/src/handler/dependencies/provider.ts +++ b/src/handler/dependencies/provider.ts @@ -1,90 +1,72 @@ import type { Container } from 'iti'; import { SernError } from '../structures/errors'; -import { BehaviorSubject } from 'rxjs'; -import * as assert from 'assert'; -import type { Dependencies, MapDeps } from '../../types/handler'; +import type { Dependencies, DependencyConfiguration, MapDeps } from '../../types/handler'; import SernEmitter from '../sernEmitter'; -import { _const, ok } from '../utilities/functions'; -import { DefaultErrorHandling, DefaultModuleManager, Logging } from '../contracts'; +import { DefaultErrorHandling, DefaultLogging, DefaultModuleManager } from '../contracts'; import { ModuleStore } from '../structures/moduleStore'; -import { Ok, Result } from 'ts-results-es'; -import { DefaultLogging } from '../contracts'; +import { Result } from 'ts-results-es'; +import { BehaviorSubject } from 'rxjs'; +import { createContainer } from 'iti'; -export const containerSubject = new BehaviorSubject -> | null>(null); -export function composeRoot( - root: Container, Partial>, - exclusion: Set, -) { - const client = root.get('@sern/client'); - assert.ok(client !== undefined, SernError.MissingRequired); - //A utility function checking if a dependency has been declared excluded - const excluded = (key: keyof Dependencies) => exclusion.has(key); - //Wraps a fetch to the container in a Result, deferring the action - const get = (key: keyof Dependencies) => Result.wrap(() => root.get(key) as T); - const getOr = (key: keyof Dependencies, elseAction: () => unknown) => { - //Gets dependency but if an Err, map to a function that upserts. - const dep = get(key).mapErr(() => elseAction); - if (dep.err) { - //Defers upsert until final check here - return dep.val(); - } - }; - const xGetOr = (key: keyof Dependencies, action: () => unknown) => { - if (excluded(key)) { - get(key) //if dev created a dependency but excluded, deletes on root composition - .andThen(() => Ok(root.delete(key))) - .unwrapOr(ok()); - } else { - getOr(key, action); - } - }; - xGetOr('@sern/emitter', () => - root.upsert({ - '@sern/emitter': _const(new SernEmitter()), - }), - ); - //An "optional" dependency - xGetOr('@sern/logger', () => { - root.upsert({ - '@sern/logger': _const(new DefaultLogging()), +export const containerSubject = new BehaviorSubject(defaultContainer()); + +/** + * Given the user's conf, check for any excluded dependency keys. + * Then, call conf.build to get the rest of the users' dependencies. + * Finally, update the containerSubject with the new container state + * @param conf + */ +export function composeRoot(conf: DependencyConfiguration) { + //Get the current container. This should have no client or possible logger yet. + const currentContainer = containerSubject.getValue(); + const excludeLogger = conf.exclude?.has('@sern/logger'); + if(!excludeLogger) { + currentContainer.add({ + '@sern/logger' : () => new DefaultLogging() }); - }); - xGetOr('@sern/store', () => - root.upsert({ - '@sern/store': _const(new ModuleStore()), - }), - ); - xGetOr('@sern/modules', () => - root.upsert(ctx => ({ - '@sern/modules': _const(new DefaultModuleManager(ctx['@sern/store'] as ModuleStore)), - })), - ); - xGetOr('@sern/errors', () => - root.upsert({ - '@sern/errors': _const(new DefaultErrorHandling()), - }), - ); - //If logger exists, log info, else do nothing. - get('@sern/logger') - .map(logger => logger.info({ message: 'All dependencies loaded successfully' })) - .unwrapOr(ok()); + } + //Build the container based on the callback provided by the user + const container = conf.build(currentContainer); + //Check if the built container contains @sern/client or throw + // a runtime exception + Result + .wrap(() => container.get('@sern/client')) + .expect(SernError.MissingRequired); + + if(!excludeLogger) { + container.get('@sern/logger')?.info({ message: 'All dependencies loaded successfully.' }); + } + //I'm sorry little one + containerSubject.next(container as any); } export function useContainer() { - const container = containerSubject.getValue()! as unknown as Container; - assert.ok(container !== null, 'useContainer was called before Sern#init'); - //weird edge case, why can i not use _const here? + const container = containerSubject.getValue() as Container; return (...keys: [...V]) => keys.map(key => Result.wrap(() => container.get(key)).unwrapOr(undefined)) as MapDeps; } /** * Returns the underlying data structure holding all dependencies. + * Please be careful as this only gets the client's current state. * Exposes some methods from iti */ -export function useContainerRaw() { - return containerSubject.getValue(); +export function useContainerRaw() { + return containerSubject.getValue() as Container; } + +/** + * Provides all the defaults for sern to function properly. + * The only user provided dependency needs to be @sern/client + */ +function defaultContainer() { + return createContainer() + .add({ '@sern/errors': () => new DefaultErrorHandling()}) + .add({ '@sern/store' : () => new ModuleStore()}) + .add(ctx => { + return { + '@sern/modules': () => new DefaultModuleManager(ctx['@sern/store']) + }; + }) + .add({ '@sern/emitter': () => new SernEmitter()}) as Container, {}>; +} \ No newline at end of file diff --git a/src/handler/events/dispatchers.ts b/src/handler/events/dispatchers.ts deleted file mode 100644 index 6b3e0bf..0000000 --- a/src/handler/events/dispatchers.ts +++ /dev/null @@ -1,209 +0,0 @@ -import Context from '../structures/context'; -import type { Payload, SlashOptions } from '../../types/handler'; -import { arrAsync } from '../utilities/arrAsync'; -import { controller } from '../sern'; -import type { - ButtonInteraction, - ModalSubmitInteraction, - AutocompleteInteraction, - ChatInputCommandInteraction, - Interaction, - UserContextMenuCommandInteraction, - MessageContextMenuCommandInteraction, -} from 'discord.js'; -import { SernError } from '../structures/errors'; -import treeSearch from '../utilities/treeSearch'; -import type { - BothCommand, - ButtonCommand, - ContextMenuMsg, - ContextMenuUser, - ModalSubmitCommand, - StringSelectCommand, - SlashCommand, - UserSelectCommand, - ChannelSelectCommand, - MentionableSelectCommand, - RoleSelectCommand, -} from '../../types/module'; -import type SernEmitter from '../sernEmitter'; -import { EventEmitter } from 'events'; -import type { - DiscordEventCommand, - ExternalEventCommand, - SernEventCommand, -} from '../structures/events'; -import * as assert from 'assert'; -import { reducePlugins } from '../utilities/functions'; -import { concatMap, from, fromEvent, map, of } from 'rxjs'; -import type { MessageComponentInteraction } from 'discord.js'; - -export function applicationCommandDispatcher(interaction: Interaction) { - if (interaction.isAutocomplete()) { - return dispatchAutocomplete(interaction); - } else { - const ctx = Context.wrap(interaction as ChatInputCommandInteraction); - const args: ['slash', SlashOptions] = ['slash', ctx.interaction.options]; - return (mod: BothCommand | SlashCommand) => ({ - mod, - execute: () => mod.execute(ctx, args), - eventPluginRes: arrAsync( - mod.onEvent.map(plugs => plugs.execute([ctx, args], controller)), - ), - }); - } -} - -export function dispatchAutocomplete(interaction: AutocompleteInteraction) { - return (mod: BothCommand | SlashCommand) => { - const selectedOption = treeSearch(interaction, mod.options); - if (selectedOption !== undefined) { - return { - mod, - execute: () => selectedOption.command.execute(interaction), - eventPluginRes: arrAsync( - selectedOption.command.onEvent.map(e => e.execute(interaction, controller)), - ), - }; - } - throw Error( - SernError.NotSupportedInteraction + ` There is no autocomplete tag for this option`, - ); - }; -} - -export function modalCommandDispatcher(interaction: ModalSubmitInteraction) { - return (mod: ModalSubmitCommand) => ({ - mod, - execute: () => mod.execute(interaction), - eventPluginRes: arrAsync( - mod.onEvent.map(plugs => plugs.execute([interaction], controller)), - ), - }); -} - -export function buttonCommandDispatcher(interaction: ButtonInteraction) { - return (mod: ButtonCommand) => ({ - mod, - execute: () => mod.execute(interaction), - eventPluginRes: arrAsync( - mod.onEvent.map(plugs => plugs.execute([interaction], controller)), - ), - }); -} - -export function selectMenuCommandDispatcher(interaction: MessageComponentInteraction) { - //safe casts because command type runtime check - return ( - mod: - | StringSelectCommand - | UserSelectCommand - | ChannelSelectCommand - | MentionableSelectCommand - | RoleSelectCommand, - ) => ({ - mod, - execute: () => mod.execute(interaction as never), - eventPluginRes: arrAsync( - mod.onEvent.map(plugs => plugs.execute([interaction as never], controller)), - ), - }); -} - -export function ctxMenuUserDispatcher(interaction: UserContextMenuCommandInteraction) { - return (mod: ContextMenuUser) => ({ - mod, - execute: () => mod.execute(interaction), - eventPluginRes: arrAsync( - mod.onEvent.map(plugs => plugs.execute([interaction], controller)), - ), - }); -} - -export function ctxMenuMsgDispatcher(interaction: MessageContextMenuCommandInteraction) { - return (mod: ContextMenuMsg) => ({ - mod, - execute: () => mod.execute(interaction), - eventPluginRes: arrAsync( - mod.onEvent.map(plugs => plugs.execute([interaction], controller)), - ), - }); -} - -export function sernEmitterDispatcher(e: SernEmitter) { - return (cmd: SernEventCommand & { name: string }) => ({ - source: e, - cmd, - execute: fromEvent(e, cmd.name).pipe( - map(event => ({ - event, - executeEvent: of(event).pipe( - concatMap(event => - reducePlugins( - from( - arrAsync( - cmd.onEvent.map(plug => - plug.execute([event as Payload], controller), - ), - ), - ), - ), - ), - ), - })), - ), - }); -} - -export function discordEventDispatcher(e: EventEmitter) { - return (cmd: DiscordEventCommand & { name: string }) => ({ - source: e, - cmd, - execute: fromEvent(e, cmd.name).pipe( - map(event => ({ - event, - executeEvent: of(event).pipe( - concatMap(event => - reducePlugins( - from( - arrAsync( - // god forbid I use any!!! - cmd.onEvent.map(plug => - plug.execute([event as any], controller), - ), - ), - ), - ), - ), - ), - })), - ), - }); -} - -export function externalEventDispatcher(e: (e: ExternalEventCommand) => unknown) { - return (cmd: ExternalEventCommand & { name: string }) => { - const external = e(cmd); - assert.ok(external instanceof EventEmitter, `${e} is not an EventEmitter`); - return { - source: external, - cmd, - execute: fromEvent(external, cmd.name).pipe( - map(event => ({ - event, - executeEvent: of(event).pipe( - concatMap(event => - reducePlugins( - from( - arrAsync( - cmd.onEvent.map(plug => plug.execute([event], controller)), - ), - ), - ), - ), - ), - })), - ), - }; - }; -} diff --git a/src/handler/events/dispatchers/dispatchers.ts b/src/handler/events/dispatchers/dispatchers.ts new file mode 100644 index 0000000..b1acbed --- /dev/null +++ b/src/handler/events/dispatchers/dispatchers.ts @@ -0,0 +1,64 @@ +import type { Processed } from '../../../types/handler'; +import type { AutocompleteInteraction } from 'discord.js'; +import { SernError } from '../../structures'; +import treeSearch from '../../utilities/treeSearch'; +import type { BothCommand, CommandModule, Module, SlashCommand } from '../../../types/module'; +import { EventEmitter } from 'events'; +import * as assert from 'assert'; +import { concatMap, from, fromEvent, map, OperatorFunction, pipe } from 'rxjs'; +import { callPlugin } from '../operators'; +import { createResultResolver } from '../observableHandling'; + +export function dispatchCommand(module: Processed, createArgs: () => unknown[]) { + const args = createArgs(); + return { + module, + args, + }; +} + +/** + * Creates an observable from { source } + * @param module + * @param source + */ +export function eventDispatcher(module: Processed, source: unknown) { + assert.ok(source instanceof EventEmitter, `${source} is not an EventEmitter`); + /** + * Sometimes fromEvent emits a single parameter, which is not an Array. This + * operator function flattens events into an array + * @param src + */ + const arrayify = pipe( + map(event => (Array.isArray(event) ? (event as unknown[]) : [event])), + map(args => ({ module, args })), + ); + const createResult = createResultResolver< + Processed, + { module: Processed; args: unknown[] }, + unknown[] + >({ + createStream: ({ module, args }) => from(module.onEvent).pipe(callPlugin(args)), + onSuccess: ({ args }) => args, + }); + const execute: OperatorFunction = pipe( + concatMap(async args => module.execute(...args)), + ); + return fromEvent(source, module.name).pipe(arrayify, concatMap(createResult), execute); +} + +export function dispatchAutocomplete( + module: Processed, + interaction: AutocompleteInteraction, +) { + const option = treeSearch(interaction, module.options); + if (option !== undefined) { + return { + module, + args: [interaction], + }; + } + throw Error( + SernError.NotSupportedInteraction + ` There is no autocomplete tag for this option`, + ); +} diff --git a/src/handler/events/dispatchers/index.ts b/src/handler/events/dispatchers/index.ts new file mode 100644 index 0000000..b754c3e --- /dev/null +++ b/src/handler/events/dispatchers/index.ts @@ -0,0 +1,2 @@ +export * from './dispatchers'; +export * from './provideArgs'; diff --git a/src/handler/events/dispatchers/provideArgs.ts b/src/handler/events/dispatchers/provideArgs.ts new file mode 100644 index 0000000..33aa91e --- /dev/null +++ b/src/handler/events/dispatchers/provideArgs.ts @@ -0,0 +1,23 @@ +import type { ChatInputCommandInteraction, Interaction, Message } from 'discord.js'; +import { Context } from '../../structures'; +import type { Args, SlashOptions } from '../../../types/handler'; + +/** + * function overloads to create an arguments list for Context + * @param wrap + * @param messageArgs + */ +export function contextArgs( + wrap: Message, + messageArgs?: string[], +): () => [Context, ['text', string[]]]; +export function contextArgs(wrap: Interaction): () => [Context, ['slash', SlashOptions]]; +export function contextArgs(wrap: Interaction | Message, messageArgs?: string[]) { + const ctx = Context.wrap(wrap as ChatInputCommandInteraction | Message); + const args = ctx.isMessage() ? ['text', messageArgs!] : ['slash', ctx.interaction.options]; + return () => [ctx, args] as [Context, Args]; +} + +export function interactionArg(interaction: T) { + return () => [interaction] as [T]; +} diff --git a/src/handler/events/eventsHandler.ts b/src/handler/events/eventsHandler.ts index 651cf24..393f93f 100644 --- a/src/handler/events/eventsHandler.ts +++ b/src/handler/events/eventsHandler.ts @@ -4,6 +4,9 @@ import type { EventEmitter } from 'events'; import type SernEmitter from '../sernEmitter'; import type { ErrorHandling, Logging, ModuleManager } from '../contracts'; +/** + * why did I make this, definitely going to be changed in the future + */ export abstract class EventsHandler { protected payloadSubject = new Subject(); protected abstract discordEvent: Observable; diff --git a/src/handler/events/interactionHandler.ts b/src/handler/events/interactionHandler.ts index 3617b6e..450e740 100644 --- a/src/handler/events/interactionHandler.ts +++ b/src/handler/events/interactionHandler.ts @@ -1,33 +1,20 @@ import type { Interaction } from 'discord.js'; -import { catchError, concatMap, from, fromEvent, map, Observable } from 'rxjs'; +import { catchError, concatMap, finalize, fromEvent, map, Observable } from 'rxjs'; import type Wrapper from '../structures/wrapper'; import { EventsHandler } from './eventsHandler'; -import { SernError } from '../structures/errors'; -import { CommandType, PayloadType } from '../structures/enums'; +import { CommandType, SernError, type ModuleStore } from '../structures'; import { match, P } from 'ts-pattern'; -import { - applicationCommandDispatcher, - buttonCommandDispatcher, - ctxMenuMsgDispatcher, - ctxMenuUserDispatcher, - modalCommandDispatcher, - selectMenuCommandDispatcher, -} from './dispatchers'; -import type { - ButtonInteraction, - ModalSubmitInteraction, - UserContextMenuCommandInteraction, - MessageContextMenuCommandInteraction, -} from 'discord.js'; -import { executeModule } from './observableHandling'; +import { contextArgs, interactionArg, dispatchAutocomplete, dispatchCommand } from './dispatchers'; +import { executeModule, makeModuleExecutor } from './observableHandling'; import type { CommandModule } from '../../types/module'; import { handleError } from '../contracts/errorHandling'; -import type { ModuleStore } from '../structures/moduleStore'; -import type { MessageComponentInteraction } from 'discord.js'; +import SernEmitter from '../sernEmitter'; +import type { Processed } from '../../types/handler'; +import { useContainerRaw } from '../dependencies'; export default class InteractionHandler extends EventsHandler<{ event: Interaction; - mod: CommandModule; + module: Processed; }> { protected override discordEvent: Observable; constructor(wrapper: Wrapper) { @@ -37,91 +24,112 @@ export default class InteractionHandler extends EventsHandler<{ this.payloadSubject .pipe( - map(this.processModules), - concatMap( - ({ mod, execute, eventPluginRes }) => - from(eventPluginRes).pipe(map(res => ({ mod, res, execute }))), //resolve all the Results from event plugins - ), - concatMap(payload => executeModule(wrapper, payload)), + map(this.createDispatcher), + makeModuleExecutor(module => { + this.emitter.emit( + 'module.activate', + SernEmitter.failure(module, SernError.PluginFailure), + ); + }), + concatMap(payload => executeModule(this.emitter, payload)), catchError(handleError(this.crashHandler, this.logger)), + finalize(() => { + this.logger?.info({ message: 'interactionCreate stream closed or reached end of lifetime'}); + useContainerRaw() + ?.disposeAll() + .then(() => { + this.logger?.info({ message: 'Cleaning container and crashing' }); + }); + }) ) .subscribe(); } override init() { - const get = (cb: (ms: ModuleStore) => CommandModule | undefined) => { + const get = (cb: (ms: ModuleStore) => Processed | undefined) => { return this.modules.get(cb); }; + /** + * Module retrieval: + * ModuleStores are mapped by Discord API values and modules mapped + * by customId or command name. + */ this.discordEvent.subscribe({ next: event => { if (event.isMessageComponent()) { - const mod = get(ms => + const module = get(ms => ms.InteractionHandlers[event.componentType].get(event.customId), ); - this.setState({ event, mod }); + this.setState({ event, module }); } else if (event.isCommand() || event.isAutocomplete()) { - const mod = get( + const module = get( ms => + /** + * try to fetch from ApplicationCommands, if nothing, try BothCommands + * map. If nothing again,this means a slash command + * exists on the API but not sern + */ ms.ApplicationCommands[event.commandType].get(event.commandName) ?? ms.BothCommands.get(event.commandName), ); - this.setState({ event, mod }); + this.setState({ event, module }); } else if (event.isModalSubmit()) { - const mod = get(ms => ms.ModalSubmit.get(event.customId)); - this.setState({ event, mod }); + const module = get(ms => ms.ModalSubmit.get(event.customId)); + this.setState({ event, module }); } else { throw Error('This interaction is not supported yet'); } }, error: reason => { - this.emitter.emit('error', { type: PayloadType.Failure, reason }); + this.emitter.emit('error', SernEmitter.failure(undefined, reason)); }, }); } - protected setState(state: { event: Interaction; mod: CommandModule | undefined }): void { - if (state.mod === undefined) { - this.emitter.emit('warning', { - type: PayloadType.Warning, - reason: 'Found no module for this interaction', - }); + protected setState(state: { event: Interaction; module: CommandModule | undefined }): void { + if (state.module === undefined) { + this.emitter.emit( + 'warning', + SernEmitter.warning('Found no module for this interaction'), + ); } else { //if statement above checks already, safe cast - this.payloadSubject.next(state as { event: Interaction; mod: CommandModule }); + this.payloadSubject.next( + state as { event: Interaction; module: Processed }, + ); } } - protected processModules({ mod, event }: { event: Interaction; mod: CommandModule }) { - return match(mod) - .with( - { type: P.union(CommandType.Slash, CommandType.Both) }, - applicationCommandDispatcher(event), - ) - .with( - { type: CommandType.Modal }, - modalCommandDispatcher(event as ModalSubmitInteraction), - ) - .with({ type: CommandType.Button }, buttonCommandDispatcher(event as ButtonInteraction)) - .with( - { - type: P.union( - CommandType.RoleSelect, - CommandType.StringSelect, - CommandType.UserSelect, - CommandType.MentionableSelect, - CommandType.ChannelSelect, - ), - }, - selectMenuCommandDispatcher(event as MessageComponentInteraction), - ) - .with( - { type: CommandType.CtxUser }, - ctxMenuUserDispatcher(event as UserContextMenuCommandInteraction), - ) - .with( - { type: CommandType.CtxMsg }, - ctxMenuMsgDispatcher(event as MessageContextMenuCommandInteraction), - ) - .otherwise(() => this.crashHandler.crash(Error(SernError.MismatchModule))); + protected createDispatcher({ + module, + event, + }: { + event: Interaction; + module: Processed; + }) { + return ( + match(module) + .with({ type: CommandType.Text }, () => + this.crashHandler.crash(Error(SernError.MismatchEvent)), + ) + //P.union = either CommandType.Slash or CommandType.Both + .with({ type: P.union(CommandType.Slash, CommandType.Both) }, module => { + if (event.isAutocomplete()) { + /** + * Autocomplete is a special case that + * must be handled separately, since it's + * too different from regular command modules + */ + return dispatchAutocomplete(module, event); + } else { + return dispatchCommand(module, contextArgs(event)); + } + }) + /** + * Every other command module takes a one argument parameter, its corresponding interaction + * this makes this usage safe + */ + .otherwise(mod => dispatchCommand(mod, interactionArg(event))) + ); } } diff --git a/src/handler/events/messageHandler.ts b/src/handler/events/messageHandler.ts index e723c4a..ddb4ead 100644 --- a/src/handler/events/messageHandler.ts +++ b/src/handler/events/messageHandler.ts @@ -1,39 +1,44 @@ import { EventsHandler } from './eventsHandler'; -import { catchError, concatMap, from, fromEvent, map, Observable, of, switchMap } from 'rxjs'; -import type Wrapper from '../structures/wrapper'; +import { catchError, concatMap, EMPTY, finalize, fromEvent, map, Observable, of } from 'rxjs'; +import { type Wrapper, type ModuleStore, SernError } from '../structures'; import type { Message } from 'discord.js'; -import { executeModule, ignoreNonBot, isOneOfCorrectModules } from './observableHandling'; +import { executeModule, ignoreNonBot, makeModuleExecutor } from './observableHandling'; import { fmt } from '../utilities/messageHelpers'; -import Context from '../structures/context'; -import { CommandType, PayloadType } from '../structures/enums'; -import { arrAsync } from '../utilities/arrAsync'; -import { controller } from '../sern'; -import type { CommandModule, TextCommand } from '../../types/module'; +import type { CommandModule, Module, TextCommand } from '../../types/module'; import { handleError } from '../contracts/errorHandling'; -import type { ModuleStore } from '../structures/moduleStore'; +import { contextArgs, dispatchCommand } from './dispatchers'; +import SernEmitter from '../sernEmitter'; +import type { Processed } from '../../types/handler'; +import { useContainerRaw } from '../dependencies'; export default class MessageHandler extends EventsHandler<{ - ctx: Context; - args: ['text', string[]]; - mod: TextCommand; + module: Processed; + args: unknown[]; }> { protected discordEvent: Observable; + public constructor(protected wrapper: Wrapper) { super(wrapper); this.discordEvent = >fromEvent(this.client, 'messageCreate'); this.init(); this.payloadSubject .pipe( - switchMap(({ mod, ctx, args }) => { - const res = arrAsync( - mod.onEvent.map(ep => ep.execute([ctx, args], controller)), + makeModuleExecutor(module => { + this.emitter.emit( + 'module.activate', + SernEmitter.failure(module, SernError.PluginFailure), ); - const execute = () => mod.execute(ctx, args); - //resolves the promise and re-emits it back into source - return from(res).pipe(map(res => ({ mod, execute, res }))); }), - concatMap(payload => executeModule(wrapper, payload)), + concatMap(payload => executeModule(this.emitter, payload)), catchError(handleError(this.crashHandler, this.logger)), + finalize(() => { + this.logger?.info({ message: 'messageCreate stream closed or reached end of lifetime'}); + useContainerRaw() + ?.disposeAll() + .then(() => { + this.logger?.info({ message: 'Cleaning container and crashing' }); + }); + }) ) .subscribe(); } @@ -41,34 +46,37 @@ export default class MessageHandler extends EventsHandler<{ protected init(): void { if (this.wrapper.defaultPrefix === undefined) return; //for now, just ignore if prefix doesn't exist const { defaultPrefix } = this.wrapper; - const get = (cb: (ms: ModuleStore) => CommandModule | undefined) => { + const get = (cb: (ms: ModuleStore) => Processed | undefined) => { return this.modules.get(cb); }; this.discordEvent .pipe( ignoreNonBot(this.wrapper.defaultPrefix), - map(message => { + //This concatMap checks if module is undefined, and if it is, do not continue. + // Synonymous to filterMap, but I haven't thought of a generic implementation for filterMap yet + concatMap(message => { const [prefix, ...rest] = fmt(message, defaultPrefix); - return { - ctx: Context.wrap(message), - args: <['text', string[]]>['text', rest], - mod: get(ms => ms.TextCommands.get(prefix) ?? ms.BothCommands.get(prefix)), + const module = get( + ms => ms.TextCommands.get(prefix) ?? ms.BothCommands.get(prefix), + ); + if (module === undefined) { + return EMPTY; + } + const payload = { + args: contextArgs(message, rest), + module, }; + return of(payload); }), - concatMap(element => - of(element.mod).pipe( - isOneOfCorrectModules(CommandType.Text), - map(mod => ({ ...element, mod })), - ), - ), + map(({ args, module }) => dispatchCommand(module as Processed, args)), ) .subscribe({ next: value => this.setState(value), - error: reason => this.emitter.emit('error', { type: PayloadType.Failure, reason }), + error: reason => this.emitter.emit('error', SernEmitter.failure(reason)), }); } - protected setState(state: { ctx: Context; args: ['text', string[]]; mod: TextCommand }) { + protected setState(state: { module: Processed; args: unknown[] }) { this.payloadSubject.next(state); } } diff --git a/src/handler/events/observableHandling.ts b/src/handler/events/observableHandling.ts index 53e1fa0..d627fe5 100644 --- a/src/handler/events/observableHandling.ts +++ b/src/handler/events/observableHandling.ts @@ -1,20 +1,21 @@ -import type { Message } from 'discord.js'; -import { concatMap, from, map, Observable, of, switchMap, tap, throwError, toArray } from 'rxjs'; -import { SernError } from '../structures/errors'; +import type { Awaitable, Message } from 'discord.js'; +import { concatMap, EMPTY, from, Observable, of, pipe, tap, throwError } from 'rxjs'; +import type { SernError } from '../structures'; import { Result } from 'ts-results-es'; -import type { CommandType } from '../structures/enums'; -import type Wrapper from '../structures/wrapper'; -import { PayloadType, PluginType } from '../structures/enums'; -import type { CommandModule, CommandModuleDefs, AnyModule } from '../../types/module'; -import { _const } from '../utilities/functions'; -import type SernEmitter from '../sernEmitter'; -import type { DefinedCommandModule, DefinedEventModule } from '../../types/handler'; -import type { Awaitable } from 'discord.js'; -import { processCommandPlugins } from './userDefinedEventsHandling'; +import type { AnyModule, CommandModule, EventModule, Module } from '../../types/module'; +import { _const as i } from '../utilities/functions'; +import SernEmitter from '../sernEmitter'; +import { callPlugin, everyPluginOk, filterMapTo } from './operators'; +import type { Processed } from '../../types/handler'; +import type { VoidResult } from '../../types/plugin'; -export function ignoreNonBot(prefix: string) { - return (src: Observable) => - new Observable(subscriber => { +/** + * Ignores messages from any person / bot except itself + * @param prefix + */ +export function ignoreNonBot(prefix: string) { + return (src: Observable) => + new Observable(subscriber => { return src.subscribe({ next(m) { const messageFromHumanAndHasPrefix = @@ -26,19 +27,19 @@ export function ignoreNonBot(prefix: string) { subscriber.next(m); } }, - error: e => subscriber.error(e), - complete: () => subscriber.complete(), }); }); } /** * If the current value in Result stream is an error, calls callback. + * This also extracts the Ok value from Result * @param cb + * @returns Observable<{ module: T; absPath: string }> */ export function errTap(cb: (err: SernError) => void) { - return (src: Observable>) => - new Observable<{ mod: T; absPath: string }>(subscriber => { + return (src: Observable>) => + new Observable<{ module: T; absPath: string }>(subscriber => { return src.subscribe({ next(value) { if (value.err) { @@ -47,105 +48,108 @@ export function errTap(cb: (err: SernError) => void) { subscriber.next(value.val); } }, - error: e => subscriber.error(e), - complete: () => subscriber.complete(), }); }); } -//POG -export function isOneOfCorrectModules(...inputs: [...T]) { - return (src: Observable) => { - return new Observable(subscriber => { - return src.subscribe({ - next(mod) { - if (mod === undefined) { - return throwError(_const(SernError.UndefinedModule)); - } - if (inputs.some(type => (mod.type & type) !== 0)) { - subscriber.next(mod as CommandModuleDefs[T[number]]); - } else { - return throwError(_const(SernError.MismatchModule)); - } - }, - error: e => subscriber.error(e), - complete: () => subscriber.complete(), - }); - }); - }; -} - +/** + * Wraps the task in a Result as a try / catch. + * if the task is ok, an event is emitted and the stream becomes empty + * if the task is an error, throw an error down the stream which will be handled by catchError + * @param emitter reference to SernEmitter that will emit a successful execution of module + * @param module the module that will be executed with task + * @param task the deferred execution which will be called + */ export function executeModule( - wrapper: Wrapper, - payload: { - mod: CommandModule; - execute: () => unknown; - res: Result[]; + emitter: SernEmitter, + { + module, + task, + }: { + module: Processed; + task: () => Awaitable; }, ) { - const emitter = wrapper.containerConfig.get('@sern/emitter')[0] as SernEmitter; - if (payload.res.every(el => el.ok)) { - const executeFn = Result.wrapAsync(() => - Promise.resolve(payload.execute()), - ); - return from(executeFn).pipe( - concatMap(res => { - if (res.err) { - return throwError(() => ({ - type: PayloadType.Failure, - reason: res.val, - module: payload.mod, - })); - } - return of(res.val).pipe( - tap(() => - emitter.emit('module.activate', { - type: PayloadType.Success, - module: payload.mod, - }), - ), - ); - }), - ); - } else { - emitter.emit('module.activate', { - type: PayloadType.Failure, - module: payload.mod, - reason: SernError.PluginFailure, - }); - return of(undefined); - } -} - -export function resolvePlugins({ - mod, - cmdPluginRes, -}: { - mod: DefinedCommandModule | DefinedEventModule; - cmdPluginRes: { - execute: Awaitable>; - type: PluginType.Command; - }[]; -}) { - if (mod.plugins.length === 0) { - return of({ mod, pluginRes: [] }); - } - // modules with no event plugins are ignored in the previous - return from(cmdPluginRes).pipe( - switchMap(pl => - from(pl.execute).pipe( - map(execute => ({ ...pl, execute })), - toArray(), - ), - ), - map(pluginRes => ({ mod, pluginRes })), + return of(module).pipe( + //converting the task into a promise so rxjs can resolve the Awaitable properly + concatMap(() => Result.wrapAsync(async () => task())), + concatMap(result => { + if (result.ok) { + emitter.emit('module.activate', SernEmitter.success(module)); + return EMPTY; + } else { + return throwError(i(SernEmitter.failure(module, result.val))); + } + }), ); } -export function processPlugins(payload: { - mod: DefinedCommandModule | DefinedEventModule; - absPath: string; +/** + * A higher order function that + * - creates a stream of {@link VoidResult} { config.createStream } + * - any failures results to { config.onFailure } being called + * - if all results are ok, the stream is converted to { config.onSuccess } + * emit config.onSuccess Observable + * @param config + * @returns receiver function for flattening a stream of data + */ +export function createResultResolver< + T extends Processed, + Args extends { module: T; [key: string]: unknown }, + Output, +>(config: { + onFailure?: (module: T) => unknown; + onSuccess: (args: Args) => Output; + createStream: (args: Args) => Observable; }) { - const cmdPluginRes = processCommandPlugins(payload); - return of({ mod: payload.mod, cmdPluginRes }); + return (args: Args) => { + const task$ = config.createStream(args); + return task$.pipe( + tap(result => { + if (result.err) { + config.onFailure?.(args.module); + } + }), + everyPluginOk(), + filterMapTo(() => config.onSuccess(args)), + ); + }; +} + +/** + * Calls a module's init plugins and checks for Err. If so, call { onFailure } and + * ignore the module + */ +export function scanModule< + T extends Processed, + Args extends { module: T; absPath: string }, +>(config: { onFailure?: (module: T) => unknown; onSuccess: (module: Args) => T }) { + return pipe( + concatMap( + createResultResolver({ + createStream: args => from(args.module.plugins).pipe(callPlugin(args)), + ...config, + }), + ), + ); +} + +/** + * Creates an executable task ( execute the command ) if all control plugins are successful + * @param onFailure emits a failure response to the SernEmitter + */ +export function makeModuleExecutor< + M extends Processed, + Args extends { module: M; args: unknown[] }, +>(onFailure: (m: M) => unknown) { + const onSuccess = ({ args, module }: Args) => ({ task: () => module.execute(...args), module }); + return pipe( + concatMap( + createResultResolver({ + onFailure, + createStream: ({ args, module }) => from(module.onEvent).pipe(callPlugin(args)), + onSuccess, + }), + ), + ); } diff --git a/src/handler/events/operators/index.ts b/src/handler/events/operators/index.ts new file mode 100644 index 0000000..73edbc7 --- /dev/null +++ b/src/handler/events/operators/index.ts @@ -0,0 +1,70 @@ +/** + * This file holds sern's rxjs operators used for processing data. + * Each function should be modular and testable, not bound to discord / sern + * and independent of each other + */ + +import { concatMap, defaultIfEmpty, EMPTY, every, map, of, OperatorFunction, pipe } from 'rxjs'; +import type { AnyModule } from '../../../types/module'; +import { nameOrFilename } from '../../utilities/functions'; +import type { PluginResult, VoidResult } from '../../../types/plugin'; +import { guayin } from '../../plugins'; +import { controller } from '../../sern'; + +/** + * if {src} is true, mapTo V, else ignore + * @param item + */ +export function filterMapTo(item: () => V): OperatorFunction { + return pipe(concatMap(shouldKeep => (shouldKeep ? of(item()) : EMPTY))); +} + +/** + * Calls any plugin with {args}. + * @param args if an array, its spread and plugin called. + */ +export function callPlugin(args: unknown): OperatorFunction< + { + execute: (...args: unknown[]) => PluginResult; + }, + VoidResult +> { + return pipe( + concatMap(async plugin => { + const isNewPlugin = Reflect.has(plugin, guayin); + if(isNewPlugin) { + if (Array.isArray(args)) { + return plugin.execute(...args); + } + return plugin.execute(args); + } else { + return plugin.execute(args, controller); + } + }), + ); +} + +/** + * operator function that fill the defaults for a module + */ +export function defineAllFields() { + const fillFields = ({ absPath, module }: { absPath: string; module: T }) => ({ + absPath, + module: { + name: nameOrFilename(module.name, absPath), + description: module.description ?? '...', + ...module, + }, + }); + return pipe(map(fillFields)); +} + +/** + * Checks if the stream of results is all ok. + */ +export function everyPluginOk(): OperatorFunction { + return pipe( + every(result => result.ok), + defaultIfEmpty(true), + ); +} diff --git a/src/handler/events/readyHandler.ts b/src/handler/events/readyHandler.ts index e0f7a18..fd6856e 100644 --- a/src/handler/events/readyHandler.ts +++ b/src/handler/events/readyHandler.ts @@ -1,98 +1,84 @@ import { EventsHandler } from './eventsHandler'; import type Wrapper from '../structures/wrapper'; -import { concatMap, fromEvent, Observable, map, take } from 'rxjs'; +import { concatMap, fromEvent, type Observable, take } from 'rxjs'; import * as Files from '../utilities/readFile'; -import { errTap, processPlugins, resolvePlugins } from './observableHandling'; -import { CommandType, PayloadType } from '../structures/enums'; -import { SernError } from '../structures/errors'; +import { errTap, scanModule } from './observableHandling'; +import { CommandType, SernError, type ModuleStore } from '../structures'; import { match } from 'ts-pattern'; import { Result } from 'ts-results-es'; import { ApplicationCommandType, ComponentType } from 'discord.js'; import type { CommandModule } from '../../types/module'; -import type { DefinedCommandModule, DefinedEventModule } from '../../types/handler'; +import type { Processed } from '../../types/handler'; import type { ModuleManager } from '../contracts'; -import type { ModuleStore } from '../structures/moduleStore'; -import { _const, err, nameOrFilename, ok } from '../utilities/functions'; +import { _const, err, ok } from '../utilities/functions'; +import { defineAllFields } from './operators'; +import SernEmitter from '../sernEmitter'; export default class ReadyHandler extends EventsHandler<{ - mod: DefinedCommandModule; + module: Processed; absPath: string; }> { - protected discordEvent!: Observable<{ mod: CommandModule; absPath: string }>; + protected discordEvent!: Observable<{ module: CommandModule; absPath: string }>; constructor(wrapper: Wrapper) { super(wrapper); const ready$ = fromEvent(this.client, 'ready').pipe(take(1)); this.discordEvent = ready$.pipe( concatMap(() => Files.buildData(wrapper.commands).pipe( - errTap(reason => - this.emitter.emit('module.register', { - type: PayloadType.Failure, - module: undefined, - reason, - }), - ), + errTap(reason => { + this.emitter.emit( + 'module.register', + SernEmitter.failure(undefined, reason), + ); + }), ), ), ); this.init(); this.payloadSubject - .pipe(concatMap(processPlugins), concatMap(resolvePlugins)) - .subscribe(payload => { - const allPluginsSuccessful = payload.pluginRes.every(({ execute }) => execute.ok); - if (allPluginsSuccessful) { - const res = registerModule(this.modules, payload.mod); - if (res.err) { - this.crashHandler.crash(Error(SernError.InvalidModuleType)); - } - this.emitter.emit('module.register', { - type: PayloadType.Success, - module: payload.mod, - }); - } else { - this.emitter.emit('module.register', { - type: PayloadType.Failure, - module: payload.mod, - reason: SernError.PluginFailure, - }); + .pipe( + scanModule({ + onFailure: module => { + this.emitter.emit( + 'module.register', + SernEmitter.failure(module, SernError.PluginFailure), + ); + }, + onSuccess: ({ module }) => { + this.emitter.emit('module.register', SernEmitter.success(module)); + return module; + }, + }), + ) + .subscribe(module => { + const res = registerModule(this.modules, module as Processed); + if (res.err) { + this.crashHandler.crash(Error(SernError.InvalidModuleType)); } }); } - private static intoDefinedModule({ absPath, mod }: { absPath: string; mod: CommandModule }): { - absPath: string; - mod: DefinedCommandModule; - } { - return { - absPath, - mod: { - name: nameOrFilename(mod.name, absPath), - description: mod?.description ?? '...', - ...mod, - }, - }; - } protected init() { - this.discordEvent.pipe(map(ReadyHandler.intoDefinedModule)).subscribe({ + this.discordEvent.pipe(defineAllFields()).subscribe({ next: value => this.setState(value), complete: () => this.payloadSubject.unsubscribe(), }); } - protected setState(state: { absPath: string; mod: DefinedCommandModule }): void { + protected setState(state: { absPath: string; module: Processed }): void { this.payloadSubject.next(state); } } -function registerModule( +function registerModule>( manager: ModuleManager, - mod: DefinedCommandModule | DefinedEventModule, + mod: T, ): Result { const name = mod.name; const insert = (cb: (ms: ModuleStore) => void) => { const set = Result.wrap(_const(manager.set(cb))); return set.ok ? ok() : err(); }; - return match(mod) + return match(mod as Processed) .with({ type: CommandType.Text }, mod => { mod.alias?.forEach(a => insert(ms => ms.TextCommands.set(a, mod))); return insert(ms => ms.TextCommands.set(name, mod)); diff --git a/src/handler/events/userDefinedEventsHandling.ts b/src/handler/events/userDefinedEventsHandling.ts index 7c43e9a..e49f45b 100644 --- a/src/handler/events/userDefinedEventsHandling.ts +++ b/src/handler/events/userDefinedEventsHandling.ts @@ -1,45 +1,20 @@ -import { catchError, concatMap, filter, from, iif, map, of, tap, toArray } from 'rxjs'; +import { catchError, finalize, map, tap } from 'rxjs'; import { buildData } from '../utilities/readFile'; -import { controller } from '../sern'; -import type { DefinedCommandModule, DefinedEventModule, Dependencies } from '../../types/handler'; -import { PayloadType, PluginType } from '../structures/enums'; -import type Wrapper from '../structures/wrapper'; -import { isDiscordEvent, isExternalEvent, isSernEvent } from '../utilities/predicates'; -import { errTap, processPlugins, resolvePlugins } from './observableHandling'; -import type { AnyModule, EventModule } from '../../types/module'; +import type { Dependencies, Processed } from '../../types/handler'; +import { errTap, scanModule } from './observableHandling'; +import type { CommandModule, EventModule } from '../../types/module'; import type { EventEmitter } from 'events'; -import type SernEmitter from '../sernEmitter'; -import { nameOrFilename, reducePlugins } from '../utilities/functions'; +import SernEmitter from '../sernEmitter'; import { match } from 'ts-pattern'; -import { - discordEventDispatcher, - externalEventDispatcher, - sernEmitterDispatcher, -} from './dispatchers'; import type { ErrorHandling, Logging } from '../contracts'; -import { SernError } from '../structures/errors'; +import { SernError, EventType, type Wrapper } from '../structures'; +import { eventDispatcher } from './dispatchers'; import { handleError } from '../contracts/errorHandling'; -import type { Awaitable } from 'discord.js'; -import type { Result } from 'ts-results-es'; - -/** - * Utility function to process command plugins for all Modules - * @param payload - */ -export function processCommandPlugins< - T extends DefinedCommandModule | DefinedEventModule, ->(payload: { - mod: T; - absPath: string; -}): { type: PluginType.Command; execute: Awaitable> }[] { - return payload.mod.plugins.map(plug => ({ - type: plug.type, - execute: plug.execute(payload as any, controller), - })); -} +import { defineAllFields } from './operators'; +import { useContainerRaw } from '../dependencies'; export function processEvents({ containerConfig, events }: Wrapper) { - const [client, error, sernEmitter, logging] = containerConfig.get( + const [client, errorHandling, sernEmitter, logger] = containerConfig.get( '@sern/client', '@sern/errors', '@sern/emitter', @@ -47,78 +22,51 @@ export function processEvents({ containerConfig, events }: Wrapper) { ) as [EventEmitter, ErrorHandling, SernEmitter, Logging?]; const lazy = (k: string) => containerConfig.get(k as keyof Dependencies)[0]; const eventStream$ = eventObservable$(events!, sernEmitter); - const emitSuccess$ = (mod: AnyModule) => - of({ type: PayloadType.Failure, module: mod, reason: SernError.PluginFailure }).pipe( - tap(it => sernEmitter.emit('module.register', it)), - ); - const emitFailure$ = (mod: AnyModule) => - of({ type: PayloadType.Success, module: mod } as const).pipe( - tap(it => sernEmitter.emit('module.register', it)), - ); + const eventCreation$ = eventStream$.pipe( - map(({ mod, absPath }) => ({ - mod: { - name: nameOrFilename(mod.name, absPath), - ...mod, - } as DefinedEventModule, - absPath, - })), - concatMap(processPlugins), - concatMap(resolvePlugins), - //Reduces pluginRes (generated from above) into a single boolean - concatMap(({ pluginRes, mod }) => - from(pluginRes).pipe( - map(pl => pl.execute), - toArray(), - reducePlugins, - map(success => ({ success, mod })), - ), - ), - concatMap(({ success, mod }) => - iif(() => success, emitFailure$(mod), emitSuccess$(mod)).pipe( - filter(res => res.type === PayloadType.Success), - map(() => mod), - ), - ), + defineAllFields(), + scanModule({ + onFailure: module => sernEmitter.emit('module.register', SernEmitter.success(module)), + onSuccess: ({ module }) => { + sernEmitter.emit( + 'module.register', + SernEmitter.failure(module, SernError.PluginFailure), + ); + return module; + }, + }), ); - eventCreation$.subscribe(e => { - const payload = match(e) - .when(isSernEvent, sernEmitterDispatcher(sernEmitter)) - .when(isDiscordEvent, discordEventDispatcher(client)) - .when( - isExternalEvent, - externalEventDispatcher(e => lazy(e.emitter)), - ) - .otherwise(() => error.crash(Error(SernError.InvalidModuleType))); - payload.execute - .pipe( - concatMap(({ event, executeEvent }) => - executeEvent.pipe( - tap(success => { - if (success) { - if (Array.isArray(event)) { - payload.cmd.execute(...event); - } else { - payload.cmd.execute(event as never); - } - } - }), - catchError(handleError(error, logging)), - ), - ), - ) - .subscribe(); - }); + const intoDispatcher = (e: Processed) => + match(e) + .with({ type: EventType.Sern }, m => eventDispatcher(m, sernEmitter)) + .with({ type: EventType.Discord }, m => eventDispatcher(m, client)) + .with({ type: EventType.External }, m => eventDispatcher(m, lazy(m.emitter))) + .otherwise(() => errorHandling.crash(Error(SernError.InvalidModuleType))); + + eventCreation$ + .pipe( + map(intoDispatcher), + /** + * Where all events are turned on + */ + tap(dispatcher => dispatcher.subscribe()), + catchError(handleError(errorHandling, logger)), + finalize(() => { + logger?.info({ message: 'an event module reached end of lifetime'}); + useContainerRaw() + ?.disposeAll() + .then(() => { + logger?.info({ message: 'Cleaning container and crashing' }); + }); + }) + ) + .subscribe(); } function eventObservable$(events: string, emitter: SernEmitter) { return buildData(events).pipe( - errTap(reason => - emitter.emit('module.register', { - type: PayloadType.Failure, - module: undefined, - reason, - }), - ), + errTap(reason => { + emitter.emit('module.register', SernEmitter.failure(undefined, reason)); + }), ); } diff --git a/src/handler/plugins/args.ts b/src/handler/plugins/args.ts new file mode 100644 index 0000000..e12e46a --- /dev/null +++ b/src/handler/plugins/args.ts @@ -0,0 +1,116 @@ +import type { CommandType } from '../structures/enums'; +import type { PluginType } from '../structures/enums'; +import type { ClientEvents } from 'discord.js'; +import type { + BothCommand, + ButtonCommand, + ChannelSelectCommand, + ContextMenuUser, + DiscordEventCommand, + ExternalEventCommand, + MentionableSelectCommand, + ModalSubmitCommand, + RoleSelectCommand, + SernEventCommand, + SlashCommand, + StringSelectCommand, + TextCommand, + UserSelectCommand, + ContextMenuMsg, Module, +} from '../../types/module'; +import type { Args, Payload, Processed, SlashOptions } from '../../types/handler'; +import type Context from '../structures/context'; +import type { MessageContextMenuCommandInteraction } from 'discord.js'; +import type { + ButtonInteraction, + RoleSelectMenuInteraction, + StringSelectMenuInteraction, + UserContextMenuCommandInteraction, +} from 'discord.js'; +import type { + ChannelSelectMenuInteraction, + MentionableSelectMenuInteraction, + ModalSubmitInteraction, + UserSelectMenuInteraction, +} from 'discord.js'; +import { EventType } from '../structures/enums'; + +type CommandArgsMatrix = { + [CommandType.Text]: { + [PluginType.Control]: [Context, ['text', string[]]]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.Slash]: { + [PluginType.Control]: [Context, ['slash', /* library coupled */ SlashOptions]]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.Both]: { + [PluginType.Control]: [Context, Args]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.CtxMsg]: { + [PluginType.Control]: [/* library coupled */ MessageContextMenuCommandInteraction]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.CtxUser]: { + [PluginType.Control]: [/* library coupled */ UserContextMenuCommandInteraction]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.Button]: { + [PluginType.Control]: [/* library coupled */ ButtonInteraction]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.StringSelect]: { + [PluginType.Control]: [/* library coupled */ StringSelectMenuInteraction]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.RoleSelect]: { + [PluginType.Control]: [/* library coupled */ RoleSelectMenuInteraction]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.ChannelSelect]: { + [PluginType.Control]: [/* library coupled */ ChannelSelectMenuInteraction]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.MentionableSelect]: { + [PluginType.Control]: [/* library coupled */ MentionableSelectMenuInteraction]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.UserSelect]: { + [PluginType.Control]: [/* library coupled */ UserSelectMenuInteraction]; + [PluginType.Init]: [InitArgs>]; + }; + [CommandType.Modal]: { + [PluginType.Control]: [/* library coupled */ ModalSubmitInteraction]; + [PluginType.Init]: [InitArgs>]; + }; +}; + +type EventArgsMatrix = { + [EventType.Discord]: { + [PluginType.Control]: /* library coupled */ ClientEvents[keyof ClientEvents]; + [PluginType.Init]: [InitArgs>]; + }; + [EventType.Sern]: { + [PluginType.Control]: [Payload]; + [PluginType.Init]: [InitArgs>]; + }; + [EventType.External]: { + [PluginType.Control]: unknown[]; + [PluginType.Init]: [InitArgs>]; + }; +}; + +export interface InitArgs> { + module: T; + absPath: string; +} + +export type CommandArgs< + I extends CommandType = CommandType, + J extends PluginType = PluginType, +> = CommandArgsMatrix[I][J]; +export type EventArgs< + I extends EventType = EventType, + J extends PluginType = PluginType, +> = EventArgsMatrix[I][J]; diff --git a/src/handler/plugins/createPlugin.ts b/src/handler/plugins/createPlugin.ts new file mode 100644 index 0000000..01a806d --- /dev/null +++ b/src/handler/plugins/createPlugin.ts @@ -0,0 +1,51 @@ +import { CommandType, EventType, PluginType } from '../structures/enums'; +import type { Plugin, PluginResult } from '../../types/plugin'; +import type { CommandArgs, EventArgs } from './args'; +import type { ClientEvents } from 'discord.js'; +export const guayin = Symbol('twice<3'); +export function makePlugin( + type: PluginType, + execute: (...args: any[]) => any, +): Plugin { + return { + type, + execute, + [guayin]: undefined + } as Plugin; +} + +export function EventInitPlugin( + execute: (...args: EventArgs) => PluginResult, +) { + return makePlugin(PluginType.Init, execute); +} + +export function CommandInitPlugin( + execute: (...args: CommandArgs) => PluginResult, +) { + return makePlugin(PluginType.Init, execute); +} + +export function CommandControlPlugin( + execute: (...args: CommandArgs) => PluginResult, +) { + return makePlugin(PluginType.Control, execute); +} + +export function EventControlPlugin( + execute: (...args: EventArgs) => PluginResult, +) { + return makePlugin(PluginType.Control, execute); +} + +/** + * @Experimental + * A specialized function for creating control plugins with discord.js ClientEvents. + * Will probably be moved one day! + */ +export function DiscordEventControlPlugin( + name: T, + execute: (...args: ClientEvents[T]) => PluginResult, +) { + return makePlugin(PluginType.Control, execute); +} diff --git a/src/handler/plugins/index.ts b/src/handler/plugins/index.ts new file mode 100644 index 0000000..3f8a662 --- /dev/null +++ b/src/handler/plugins/index.ts @@ -0,0 +1,2 @@ +export { EventArgs, InitArgs, CommandArgs } from './args'; +export * from './createPlugin'; diff --git a/src/handler/plugins/plugin.ts b/src/handler/plugins/plugin.ts deleted file mode 100644 index 2f67503..0000000 --- a/src/handler/plugins/plugin.ts +++ /dev/null @@ -1,147 +0,0 @@ -/* - * Plugins can be inserted on all commands and are emitted - * - * 1. On ready event, where all commands are loaded. - * 2. On corresponding observable (when command triggers) - * - * The goal of plugins is to organize commands and - * provide extensions to repetitive patterns - * examples include refreshing modules, - * categorizing commands, cool-downs, permissions, etc. - * Plugins are reminiscent of middleware in express. - */ - -import type { AutocompleteInteraction, Awaitable, ClientEvents } from 'discord.js'; -import type { Result, Ok, Err } from 'ts-results-es'; -import type { CommandType, SernEventsMapping } from '../../index'; -import { EventType, PluginType } from '../../index'; -import type { CommandModuleDefs, EventModuleDefs } from '../../types/module'; -import type { - DiscordEventCommand, - ExternalEventCommand, - SernEventCommand, -} from '../structures/events'; - -export interface Controller { - next: () => Ok; - stop: () => Err; -} -export interface Plugin { - /** @deprecated will be removed in the next update */ - name?: string; - /** @deprecated will be removed in the next update */ - description?: string; - type: PluginType; -} - -export interface CommandPlugin - extends Plugin { - type: PluginType.Command; - execute: ( - payload: { - mod: CommandModuleDefs[T] & { name: string; description: string }; - absPath: string; - }, - controller: Controller, - ) => Awaitable>; -} -export interface DiscordEmitterPlugin extends Plugin { - type: PluginType.Command; - execute: ( - payload: { mod: DiscordEventCommand & { name: string }; absPath: string }, - controller: Controller, - ) => Awaitable>; -} - -export interface ExternalEmitterPlugin extends Plugin { - type: PluginType.Command; - execute: ( - payload: { mod: ExternalEventCommand & { name: string }; absPath: string }, - controller: Controller, - ) => Awaitable>; -} - -export interface SernEmitterPlugin extends Plugin { - type: PluginType.Command; - execute: ( - payload: { mod: SernEventCommand & { name: string }; absPath: string }, - controller: Controller, - ) => Awaitable>; -} - -export interface AutocompletePlugin extends Plugin { - type: PluginType.Event; - execute: ( - autocmp: AutocompleteInteraction, - controlller: Controller, - ) => Awaitable>; -} - -export interface EventPlugin - extends Plugin { - type: PluginType.Event; - execute: ( - event: Parameters, - controller: Controller, - ) => Awaitable>; -} - -export interface SernEventPlugin - extends Plugin { - name?: T; - type: PluginType.Event; - execute: (args: SernEventsMapping[T], controller: Controller) => Awaitable>; -} - -export interface ExternalEventPlugin extends Plugin { - type: PluginType.Event; - execute: (args: unknown[], controller: Controller) => Awaitable>; -} - -export interface DiscordEventPlugin - extends Plugin { - name?: T; - type: PluginType.Event; - execute: (args: ClientEvents[T], controller: Controller) => Awaitable>; -} - -export type CommandModuleNoPlugins = { - [T in CommandType]: Omit; -}; -export type EventModulesNoPlugins = { - [T in EventType]: Omit; -}; -/** - * Event Module Event Plugins - */ -export type EventModuleEventPluginDefs = { - [EventType.Discord]: DiscordEventPlugin; - [EventType.Sern]: SernEventPlugin; - [EventType.External]: ExternalEventPlugin; -}; - -/** - * Event Module Command Plugins - */ -export type EventModuleCommandPluginDefs = { - [EventType.Discord]: DiscordEmitterPlugin; - [EventType.Sern]: SernEmitterPlugin; - [EventType.External]: ExternalEmitterPlugin; -}; - -export type EventModulePlugin = - | EventModuleEventPluginDefs[T] - | EventModuleCommandPluginDefs[T]; - -export type CommandModulePlugin = EventPlugin | CommandPlugin; - -/** - * User inputs this type. Sern processes behind the scenes for better usage - */ -export type InputCommandModule = { - [T in CommandType]: CommandModuleNoPlugins[T] & { plugins?: CommandModulePlugin[] }; -}[CommandType]; - -export type InputEventModule = { - [T in EventType]: EventModulesNoPlugins[T] & { plugins?: EventModulePlugin[] }; -}[EventType]; diff --git a/src/handler/sern.ts b/src/handler/sern.ts index f016201..c6a0a26 100644 --- a/src/handler/sern.ts +++ b/src/handler/sern.ts @@ -1,15 +1,7 @@ import type Wrapper from './structures/wrapper'; import { processEvents } from './events/userDefinedEventsHandling'; import { CommandType, EventType, PluginType } from './structures/enums'; -import type { - Plugin, - CommandPlugin, - EventModuleCommandPluginDefs, - EventModuleEventPluginDefs, - EventPlugin, - InputCommandModule, - InputEventModule, -} from './plugins/plugin'; +import type { AnyEventPlugin, ControlPlugin, InitPlugin, Plugin } from '../types/plugin'; import InteractionHandler from './events/interactionHandler'; import ReadyHandler from './events/readyHandler'; import MessageHandler from './events/messageHandler'; @@ -18,12 +10,15 @@ import type { CommandModuleDefs, EventModule, EventModuleDefs, + InputCommand, + InputEvent, } from '../types/module'; -import { Container, createContainer } from 'iti'; -import type { Dependencies, OptionalDependencies } from '../types/handler'; -import { composeRoot, containerSubject, useContainer } from './dependencies/provider'; +import type { Dependencies, DependencyConfiguration } from '../types/handler'; +import { composeRoot, useContainer } from './dependencies/provider'; import type { Logging } from './contracts'; import { err, ok, partition } from './utilities/functions'; +import type { Awaitable, ClientEvents } from 'discord.js'; + /** * * @param wrapper Options to pass into sern. @@ -66,10 +61,10 @@ export const controller = { * The wrapper function to define command modules for sern * @param mod */ -export function commandModule(mod: InputCommandModule): CommandModule { +export function commandModule(mod: InputCommand): CommandModule { const [onEvent, plugins] = partition( mod.plugins ?? [], - el => (el as Plugin).type === PluginType.Event, + el => (el as Plugin).type === PluginType.Control, ); return { ...mod, @@ -81,10 +76,10 @@ export function commandModule(mod: InputCommandModule): CommandModule { * The wrapper function to define event modules for sern * @param mod */ -export function eventModule(mod: InputEventModule): EventModule { +export function eventModule(mod: InputEvent): EventModule { const [onEvent, plugins] = partition( mod.plugins ?? [], - el => (el as Plugin).type === PluginType.Event, + el => (el as Plugin).type === PluginType.Control, ); return { ...mod, @@ -92,29 +87,45 @@ export function eventModule(mod: InputEventModule): EventModule { plugins, } as EventModule; } + +/** + * Create event modules from discord.js client events, + * This is an {@link eventModule} for discord events, + * where typings can be very bad. + * @param mod + */ +export function discordEvent(mod: { + name: T; + plugins?: AnyEventPlugin[]; + execute: (...args: ClientEvents[T]) => Awaitable; +}) { + return eventModule({ type: EventType.Discord, ...mod }); +} /** * @param conf a configuration for creating your project dependencies */ -export function makeDependencies(conf: { - exclude?: Set; - build: (root: Container, {}>) => Container, T>; -}) { - const container = conf.build(createContainer()); - composeRoot(container, conf.exclude ?? new Set()); - containerSubject.next(container as unknown as Container); +export function makeDependencies(conf: DependencyConfiguration) { + //Until there are more optional dependencies, just check if the logger exists + composeRoot(conf); return useContainer(); } +/** + * @Experimental + * Will be refactored / changed in future + */ export abstract class CommandExecutable { abstract type: Type; - plugins: CommandPlugin[] = []; - onEvent: EventPlugin[] = []; + plugins: InitPlugin[] = []; + onEvent: ControlPlugin[] = []; abstract execute: CommandModuleDefs[Type]['execute']; } - +/**@Experimental + * Will be refactored in future + */ export abstract class EventExecutable { abstract type: Type; - plugins: EventModuleCommandPluginDefs[Type][] = []; - onEvent: EventModuleEventPluginDefs[Type][] = []; + plugins: InitPlugin[] = []; + onEvent: ControlPlugin[] = []; abstract execute: EventModuleDefs[Type]['execute']; } diff --git a/src/handler/sernEmitter.ts b/src/handler/sernEmitter.ts index e643ceb..0a602ec 100644 --- a/src/handler/sernEmitter.ts +++ b/src/handler/sernEmitter.ts @@ -1,5 +1,7 @@ import { EventEmitter } from 'events'; -import type { SernEventsMapping } from '../types/handler'; +import type { Payload, SernEventsMapping } from '../types/handler'; +import { PayloadType } from './structures'; +import type { Module } from '../types/module'; class SernEmitter extends EventEmitter { /** @@ -35,6 +37,49 @@ class SernEmitter extends EventEmitter { ): boolean { return super.emit(eventName, ...args); } + private static payload( + type: PayloadType, + module?: Module, + reason?: unknown, + ) { + return { type, module, reason } as T; + } + + /** + * Creates a compliant SernEmitter failure payload + * @param module + * @param reason + */ + static failure(module?: Module, reason?: unknown) { + //The generic cast Payload & { type : PayloadType.* } coerces the type to be a failure payload + // same goes to the other methods below + return SernEmitter.payload( + PayloadType.Failure, + module, + reason, + ); + } + /** + * Creates a compliant SernEmitter module success payload + * @param module + */ + static success(module: Module) { + return SernEmitter.payload( + PayloadType.Success, + module, + ); + } + /** + * Creates a compliant SernEmitter module warning payload + * @param reason + */ + static warning(reason: unknown) { + return SernEmitter.payload( + PayloadType.Warning, + undefined, + reason, + ); + } } export default SernEmitter; diff --git a/src/handler/structures/context.ts b/src/handler/structures/context.ts index c7a12ce..d87b69b 100644 --- a/src/handler/structures/context.ts +++ b/src/handler/structures/context.ts @@ -39,11 +39,11 @@ export default class Context { } public get id(): Snowflake { - return safeUnwrap(this.ctx.map(m => m.id).mapErr(i => i.id)); + return this.ctx.val.id; } public get channel() { - return safeUnwrap(this.ctx.map(m => m.channel).mapErr(i => i.channel)); + return this.ctx.val.channel; } /** * If context is holding a message, message.author @@ -54,30 +54,30 @@ export default class Context { } public get createdTimestamp(): number { - return safeUnwrap(this.ctx.map(m => m.createdTimestamp).mapErr(i => i.createdTimestamp)); + return this.ctx.val.createdTimestamp; } public get guild() { - return safeUnwrap(this.ctx.map(m => m.guild).mapErr(i => i.guild)); + return this.ctx.val.guild; } public get guildId() { - return safeUnwrap(this.ctx.map(m => m.guildId).mapErr(i => i.guildId)); + return this.ctx.val.guildId; } /* * interactions can return APIGuildMember if the guild it is emitted from is not cached */ public get member() { - return safeUnwrap(this.ctx.map(m => m.member).mapErr(i => i.member)); + return this.ctx.val.member; } public get client(): Client { - return safeUnwrap(this.ctx.map(m => m.client).mapErr(i => i.client)); + return this.ctx.val.client; } public get inGuild(): boolean { - return safeUnwrap(this.ctx.map(m => m.inGuild()).mapErr(i => i.inGuild())); + return this.ctx.val.inGuild(); } public isMessage() { return this.ctx.map(() => true).unwrapOr(false); diff --git a/src/handler/structures/enums.ts b/src/handler/structures/enums.ts index fa18e42..d4dd804 100644 --- a/src/handler/structures/enums.ts +++ b/src/handler/structures/enums.ts @@ -102,13 +102,23 @@ export enum EventType { */ export enum PluginType { /** - * The PluginType for CommandPlugins + * The PluginType for InitPlugins + */ + Init = 1, + /** + * @deprecated + * Use PluginType.Init instead */ Command = 1, /** - * The PluginType for EventPlugins + * @deprecated + * Use PluginType.Control instead */ Event = 2, + /** + * The PluginType for EventPlugins + */ + Control = 2, } /** * @enum { string } diff --git a/src/handler/structures/events.ts b/src/handler/structures/events.ts deleted file mode 100644 index 013957d..0000000 --- a/src/handler/structures/events.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type { SernEventsMapping } from '../../types/handler'; -import type { - DiscordEmitterPlugin, - DiscordEventPlugin, - ExternalEmitterPlugin, - ExternalEventPlugin, - SernEmitterPlugin, - SernEventPlugin, -} from '../plugins/plugin'; -import type { Awaitable, ClientEvents } from 'discord.js'; -import type { EventType } from './enums'; -import type { Module } from '../../types/module'; - -export interface SernEventCommand - extends Module { - name?: T; - type: EventType.Sern; - onEvent: SernEventPlugin[]; - plugins: SernEmitterPlugin[]; - execute(...args: SernEventsMapping[T]): Awaitable; -} - -export interface DiscordEventCommand - extends Module { - name?: T; - type: EventType.Discord; - onEvent: DiscordEventPlugin[]; - plugins: DiscordEmitterPlugin[]; - execute(...args: ClientEvents[T]): Awaitable; -} - -export interface ExternalEventCommand extends Module { - name?: string; - emitter: string; - type: EventType.External; - onEvent: ExternalEventPlugin[]; - plugins: ExternalEmitterPlugin[]; - execute(...args: unknown[]): Awaitable; -} diff --git a/src/handler/structures/structxports.ts b/src/handler/structures/index.ts similarity index 87% rename from src/handler/structures/structxports.ts rename to src/handler/structures/index.ts index 97219c6..917094c 100644 --- a/src/handler/structures/structxports.ts +++ b/src/handler/structures/index.ts @@ -1,6 +1,6 @@ import Context from './context'; import type Wrapper from './wrapper'; import { ModuleStore } from './moduleStore'; - +export * from './errors'; export * from './enums'; export { Context, Wrapper, ModuleStore }; diff --git a/src/handler/structures/moduleStore.ts b/src/handler/structures/moduleStore.ts index 0446dcf..29a89ad 100644 --- a/src/handler/structures/moduleStore.ts +++ b/src/handler/structures/moduleStore.ts @@ -1,25 +1,26 @@ import type { CommandModule } from '../../types/module'; import { ApplicationCommandType, ComponentType } from 'discord.js'; +import type { Processed } from '../../types/handler'; /** * Storing all command modules * This dependency is usually injected into ModuleManager */ export class ModuleStore { - readonly BothCommands = new Map(); + readonly BothCommands = new Map>(); readonly ApplicationCommands = { - [ApplicationCommandType.User]: new Map(), - [ApplicationCommandType.Message]: new Map(), - [ApplicationCommandType.ChatInput]: new Map(), + [ApplicationCommandType.User]: new Map>(), + [ApplicationCommandType.Message]: new Map>(), + [ApplicationCommandType.ChatInput]: new Map>(), }; - readonly ModalSubmit = new Map(); - readonly TextCommands = new Map(); + readonly ModalSubmit = new Map>(); + readonly TextCommands = new Map>(); readonly InteractionHandlers = { - [ComponentType.Button]: new Map(), - [ComponentType.StringSelect]: new Map(), - [ComponentType.ChannelSelect]: new Map(), - [ComponentType.MentionableSelect]: new Map(), - [ComponentType.RoleSelect]: new Map(), - [ComponentType.UserSelect]: new Map(), + [ComponentType.Button]: new Map>(), + [ComponentType.StringSelect]: new Map>(), + [ComponentType.ChannelSelect]: new Map>(), + [ComponentType.MentionableSelect]: new Map>(), + [ComponentType.RoleSelect]: new Map>(), + [ComponentType.UserSelect]: new Map>(), }; } diff --git a/src/handler/utilities/arrAsync.ts b/src/handler/utilities/arrAsync.ts deleted file mode 100644 index b12aec2..0000000 --- a/src/handler/utilities/arrAsync.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Awaitable } from 'discord.js'; - -export async function arrAsync(promiseLike: Awaitable[]): Promise { - const arr: T[] = []; - for await (const el of promiseLike) { - arr.push(el); - } - return arr; -} diff --git a/src/handler/utilities/functions.ts b/src/handler/utilities/functions.ts index 3480a29..fbee0b1 100644 --- a/src/handler/utilities/functions.ts +++ b/src/handler/utilities/functions.ts @@ -1,26 +1,18 @@ import * as Files from './readFile'; import { basename } from 'path'; -import { Err, Ok, Result } from 'ts-results-es'; -import { Observable, of, switchMap } from 'rxjs'; +import { Err, Ok } from 'ts-results-es'; /** * A function that returns whatever value is provided. - * Used for singleton in iti + * Warning: this evaluates { @param value }. It does not defer a value. * @param value */ -export const _const = - (value: T) => - () => - value; +// prettier-ignore +export const _const = (value: T) => () => value; /** - * A function that returns another function - * Used for transient in iti - * @param value + * + * @param modName + * @param absPath */ -export const transient = - (value: T) => - () => - _const(value); - export function nameOrFilename(modName: string | undefined, absPath: string) { return modName ?? Files.fmtFileName(basename(absPath)); } @@ -41,7 +33,3 @@ export function partition(arr: (T & V)[], condition: (e: T & V) => boolean } return [t, v]; } - -export function reducePlugins(src: Observable[]>): Observable { - return src.pipe(switchMap(s => of(s.every(a => a.ok)))); -} diff --git a/src/handler/utilities/predicates.ts b/src/handler/utilities/predicates.ts deleted file mode 100644 index b38d372..0000000 --- a/src/handler/utilities/predicates.ts +++ /dev/null @@ -1,26 +0,0 @@ -import type { - DiscordEventCommand, - ExternalEventCommand, - SernEventCommand, -} from '../structures/events'; -import { CommandModule, EventType } from '../..'; -import type { AnyModule, CommandModuleDefs, EventModule } from '../../types/module'; - -export function correctModuleType( - plug: AnyModule | undefined, - type: T, -): plug is CommandModuleDefs[T] { - // Another way to check if type is equivalent, - // It will check based on flag system instead - return plug !== undefined && (plug.type & type) !== 0; -} - -export function isDiscordEvent(el: EventModule | CommandModule): el is DiscordEventCommand { - return el.type === EventType.Discord; -} -export function isSernEvent(el: EventModule | CommandModule): el is SernEventCommand { - return el.type === EventType.Sern; -} -export function isExternalEvent(el: EventModule | CommandModule): el is ExternalEventCommand { - return el.type === EventType.External && 'emitter' in el; -} diff --git a/src/handler/utilities/readFile.ts b/src/handler/utilities/readFile.ts index 707689f..3df7a65 100644 --- a/src/handler/utilities/readFile.ts +++ b/src/handler/utilities/readFile.ts @@ -22,7 +22,8 @@ function readPath(dir: string, arrayOfFiles: string[] = []): string[] { export const fmtFileName = (n: string) => n.substring(0, n.length - 3); /** - * + * a directory string is converted into a stream of modules. + * starts the stream of modules that sern needs to process on init * @returns {Observable<{ mod: Module; absPath: string; }[]>} data from command files * @param commandDir */ @@ -30,7 +31,7 @@ export const fmtFileName = (n: string) => n.substring(0, n.length - 3); export function buildData(commandDir: string): Observable< Result< { - mod: T; + module: T; absPath: string; }, SernError @@ -40,20 +41,20 @@ export function buildData(commandDir: string): Observable< return from( Promise.all( commands.map(async absPath => { - let mod: T | undefined; + let module: T | undefined; try { // eslint-disable-next-line @typescript-eslint/no-var-requires - mod = require(absPath).default; + module = require(absPath).default; } catch { - mod = (await import(`file:///` + absPath)).default; + module = (await import(`file:///` + absPath)).default; } - if (mod === undefined) { + if (module === undefined) { return Err(SernError.UndefinedModule); } try { - mod = new (mod as unknown as new () => T)(); + module = new (module as unknown as new () => T)(); } catch {} - return Ok({ mod, absPath }); + return Ok({ module, absPath }); }), ), ).pipe(concatAll()); diff --git a/src/handler/utilities/treeSearch.ts b/src/handler/utilities/treeSearch.ts index d158ead..16b7176 100644 --- a/src/handler/utilities/treeSearch.ts +++ b/src/handler/utilities/treeSearch.ts @@ -1,6 +1,11 @@ import { ApplicationCommandOptionType, AutocompleteInteraction } from 'discord.js'; import type { SernAutocompleteData, SernOptionsData } from '../../types/module'; +/** + * Uses an iterative DFS to check if an autocomplete node exists + * @param iAutocomplete + * @param options + */ export default function treeSearch( iAutocomplete: AutocompleteInteraction, options: SernOptionsData[] | undefined, diff --git a/src/index.ts b/src/index.ts index 8a5edec..dcb877e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,11 +1,18 @@ import SernEmitter from './handler/sernEmitter'; -export { eventModule, commandModule, EventExecutable, CommandExecutable } from './handler/sern'; +export { + eventModule, + commandModule, + EventExecutable, + CommandExecutable, + controller, + discordEvent, +} from './handler/sern'; export * as Sern from './handler/sern'; export * from './types/handler'; export * from './types/module'; -export * from './handler/structures/structxports'; -export * from './handler/plugins/plugin'; -export * from './handler/contracts/index'; +export * from './types/plugin'; +export * from './handler/structures'; +export * from './handler/plugins'; +export * from './handler/contracts'; export { SernEmitter }; -export { _const as single, transient as many } from './handler/utilities/functions'; -export { useContainerRaw } from './handler/dependencies/provider'; +export * from './handler/dependencies'; \ No newline at end of file diff --git a/src/types/handler.ts b/src/types/handler.ts index f1fb924..e128803 100644 --- a/src/types/handler.ts +++ b/src/types/handler.ts @@ -7,6 +7,7 @@ import type { UnpackFunction } from 'iti'; import type { ErrorHandling, Logging, ModuleManager } from '../handler/contracts'; import type { ModuleStore } from '../handler/structures/moduleStore'; import type SernEmitter from '../handler/sernEmitter'; +import type { Container } from 'iti'; // Thanks to @kelsny export type ParseType = { [K in keyof T]: T[K] extends unknown ? [k: K, args: T[K]] : never; @@ -20,9 +21,7 @@ export type SlashOptions = Omit; export type Payload = | { type: PayloadType.Success; module: AnyModule } | { type: PayloadType.Failure; module?: AnyModule; reason: string | Error } @@ -50,7 +49,7 @@ export type ReplyOptions = | string | Omit | MessageReplyOptions; - +//prettier-ignore export type MapDeps = T extends [ infer First extends keyof Deps, ...infer Rest extends readonly unknown[], @@ -62,3 +61,9 @@ export type MapDeps = T : [never]; //Basically, '@sern/client' | '@sern/store' | '@sern/modules' | '@sern/error' | '@sern/emitter' will be provided defaults, and you can exclude the rest export type OptionalDependencies = '@sern/logger'; +export type Processed = T & { name: string; description: string }; +export type Deprecated = [never, Message] +export interface DependencyConfiguration { + exclude?: Set; + build: (root: Container, {}>) => Container +} \ No newline at end of file diff --git a/src/types/module.ts b/src/types/module.ts index 00c742d..544e8aa 100644 --- a/src/types/module.ts +++ b/src/types/module.ts @@ -20,121 +20,118 @@ import type { RoleSelectMenuInteraction, StringSelectMenuInteraction, } from 'discord.js'; -import type { - DiscordEventCommand, - ExternalEventCommand, - SernEventCommand, -} from '../handler/structures/events'; import { CommandType } from '../handler/structures/enums'; import type { Args, SlashOptions } from './handler'; import type Context from '../handler/structures/context'; -import type { AutocompletePlugin, CommandPlugin, EventPlugin } from '../handler/plugins/plugin'; +import type { InitPlugin, ControlPlugin } from './plugin'; import { EventType } from '../handler/structures/enums'; import type { UserSelectMenuInteraction } from 'discord.js'; +import type { AnyCommandPlugin, AnyEventPlugin } from './plugin'; +import type { SernEventsMapping } from './handler'; +import type { ClientEvents } from 'discord.js'; export interface Module { - type?: CommandType | EventType; + type: CommandType | EventType; name?: string; + onEvent: ControlPlugin[]; + plugins: InitPlugin[]; description?: string; - execute: (...args: any[]) => any; + execute: (...args: any[]) => Awaitable; } export interface TextCommand extends Module { type: CommandType.Text; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; alias?: string[]; execute: (ctx: Context, args: ['text', string[]]) => Awaitable; } export interface SlashCommand extends Module { type: CommandType.Slash; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; + description: string; options?: SernOptionsData[]; execute: (ctx: Context, args: ['slash', SlashOptions]) => Awaitable; } export interface BothCommand extends Module { type: CommandType.Both; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; alias?: string[]; + description: string; options?: SernOptionsData[]; execute: (ctx: Context, args: Args) => Awaitable; } export interface ContextMenuUser extends Module { type: CommandType.CtxUser; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; execute: (ctx: UserContextMenuCommandInteraction) => Awaitable; } export interface ContextMenuMsg extends Module { type: CommandType.CtxMsg; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; execute: (ctx: MessageContextMenuCommandInteraction) => Awaitable; } export interface ButtonCommand extends Module { type: CommandType.Button; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; execute: (ctx: ButtonInteraction) => Awaitable; } export interface StringSelectCommand extends Module { type: CommandType.StringSelect; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; execute: (ctx: StringSelectMenuInteraction) => Awaitable; } export interface ChannelSelectCommand extends Module { type: CommandType.ChannelSelect; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; execute: (ctx: ChannelSelectMenuInteraction) => Awaitable; } export interface RoleSelectCommand extends Module { type: CommandType.RoleSelect; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; execute: (ctx: RoleSelectMenuInteraction) => Awaitable; } export interface MentionableSelectCommand extends Module { type: CommandType.MentionableSelect; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; execute: (ctx: MentionableSelectMenuInteraction) => Awaitable; } export interface UserSelectCommand extends Module { type: CommandType.UserSelect; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; execute: (ctx: UserSelectMenuInteraction) => Awaitable; } export interface ModalSubmitCommand extends Module { type: CommandType.Modal; - onEvent: EventPlugin[]; - plugins: CommandPlugin[]; execute: (ctx: ModalSubmitInteraction) => Awaitable; } -export interface AutocompleteCommand extends Module { - name?: never; - description?: never; - type?: never; - onEvent: AutocompletePlugin[]; +export interface AutocompleteCommand + extends Omit { + onEvent: ControlPlugin[]; execute: (ctx: AutocompleteInteraction) => Awaitable; } +export interface SernEventCommand + extends Module { + name?: T; + type: EventType.Sern; + execute(...args: SernEventsMapping[T]): Awaitable; +} + +export interface DiscordEventCommand + extends Module { + name?: T; + type: EventType.Discord; + execute(...args: ClientEvents[T]): Awaitable; +} + +export interface ExternalEventCommand extends Module { + name?: string; + emitter: string; + type: EventType.External; + execute(...args: unknown[]): Awaitable; +} + export type EventModule = DiscordEventCommand | SernEventCommand | ExternalEventCommand; export type CommandModule = | TextCommand @@ -185,6 +182,21 @@ export interface SernAutocompleteData command: AutocompleteCommand; } +export type CommandModuleNoPlugins = { + [T in CommandType]: Omit; +}; +export type EventModulesNoPlugins = { + [T in EventType]: Omit; +}; + +export type InputEvent = { + [T in EventType]: EventModulesNoPlugins[T] & { plugins?: AnyEventPlugin[] }; +}[EventType]; + +export type InputCommand = { + [T in CommandType]: CommandModuleNoPlugins[T] & { plugins?: AnyCommandPlugin[] }; +}[CommandType]; + /** * Type that replaces autocomplete with {@link SernAutocompleteData} */ diff --git a/src/types/plugin.ts b/src/types/plugin.ts new file mode 100644 index 0000000..a55c504 --- /dev/null +++ b/src/types/plugin.ts @@ -0,0 +1,71 @@ +/* + * Plugins can be inserted on all commands and are emitted + * + * 1. On ready event, where all commands are loaded. + * 2. On corresponding observable (when command triggers) + * + * The goal of plugins is to organize commands and + * provide extensions to repetitive patterns + * examples include refreshing modules, + * categorizing commands, cool-downs, permissions, etc. + * Plugins are reminiscent of middleware in express. + */ + +import type { Awaitable } from 'discord.js'; +import type { Err, Ok, Result } from 'ts-results-es'; +import type { PluginType } from '../handler/structures/enums'; +import type { CommandModule, EventModule } from './module'; +import type { CommandArgs, InitArgs } from '../handler/plugins'; +import type { Deprecated, Processed } from './handler'; +import type { CommandType } from '../handler/structures/enums'; +export type PluginResult = Awaitable; +export type VoidResult = Result; + +export interface Controller { + next: () => Ok + stop: () => Err +} +export interface Plugin { + type: PluginType; + execute: (...args: Args) => PluginResult; +} + +export interface InitPlugin { + type: PluginType.Init; + execute: (...args: Args) => PluginResult; +} +export interface ControlPlugin { + type: PluginType.Control; + execute: (...args: Args) => PluginResult; +} + +export type AnyCommandPlugin = ControlPlugin | InitPlugin<[InitArgs>]>; +export type AnyEventPlugin = ControlPlugin | InitPlugin<[InitArgs>]>; + +/** + * @deprecated + * Use the newer helper functions and import { controller } from '@sern/handler' + */ +export interface CommandPlugin { + name?: string; + description?: string; + type: PluginType.Command; + execute: (m: InitArgs>, controller?: Deprecated<'Please import controller instead'>) => PluginResult; +} +/** + * @deprecated + * Use the newer helper functions + */ +export interface EventPlugin { + name?: string; + description?: string; + type: PluginType.Event; + execute: (args : CommandArgs, controller?: Controller) => PluginResult +} +export type DiscordEmitterPlugin = Deprecated<'Please view alternatives: '>; +export type ExternalEmitterPlugin = Deprecated<'Please view alternatives: '>; +export type SernEmitterPlugin = Deprecated<'Please view alternatives: '>; +export type AutocompletePlugin = Deprecated<'Please view alternatives: '>; +export type SernEventPlugin = Deprecated<'Please view alternatives: '>; +export type ExternalEventPlugin = Deprecated<'Please view alternatives: '>; +export type DiscordEventPlugin = Deprecated<'Please view alternatives: '>; diff --git a/test/cjs/src/index.ts b/test/cjs/src/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/test/cjs/tsconfig.json b/test/cjs/tsconfig.json deleted file mode 100644 index 2c9a949..0000000 --- a/test/cjs/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "esnext", - "module": "commonjs", - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "noImplicitAny": true, - "strictNullChecks": true, - "importsNotUsedAsValues": "error", - "baseUrl": ".", - "resolveJsonModule": true, - "moduleResolution": "node", - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/secrets.json", "src"] -} diff --git a/test/esm/src/index.ts b/test/esm/src/index.ts deleted file mode 100644 index e69de29..0000000 diff --git a/test/esm/tsconfig.json b/test/esm/tsconfig.json deleted file mode 100644 index aa5da38..0000000 --- a/test/esm/tsconfig.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "compilerOptions": { - "target": "esnext", - "module": "esnext", - "outDir": "dist", - "rootDir": "src", - "strict": true, - "esModuleInterop": true, - "noImplicitAny": true, - "strictNullChecks": true, - "importsNotUsedAsValues": "error", - "baseUrl": ".", - "resolveJsonModule": true, - "moduleResolution": "node", - "skipLibCheck": true, - "allowSyntheticDefaultImports": true, - "forceConsistentCasingInFileNames": true - }, - "include": ["src/secrets.json", "src"] -}