ready handler revamped so much cleaner

This commit is contained in:
Jacob Nguyen
2024-05-14 23:19:57 -05:00
parent 880311f08c
commit a7aea4be1a
20 changed files with 153 additions and 148 deletions

View File

@@ -36,12 +36,14 @@
"license": "MIT",
"dependencies": {
"callsites": "^3.1.0",
"node-cron": "^3.0.3",
"rxjs": "^7.8.0",
"ts-results-es": "^4.1.0"
},
"devDependencies": {
"@faker-js/faker": "^8.0.1",
"@types/node": "~18.17.11",
"@types/node": "^20.0.0",
"@types/node-cron": "^3.0.11",
"@typescript-eslint/eslint-plugin": "5.58.0",
"@typescript-eslint/parser": "5.59.1",
"discord.js": "^14.11.0",
@@ -76,7 +78,10 @@
"allowTemplateLiterals": true
}
],
"semi": [ "error", "always" ],
"semi": [
"error",
"always"
],
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/ban-types": 0,
"@typescript-eslint/no-explicit-any": "off"
@@ -87,7 +92,7 @@
"url": "git+https://github.com/sern-handler/handler.git"
},
"engines": {
"node": ">= 18.17.x"
"node": ">= 20.0.x"
},
"homepage": "https://sern.dev"
}

View File

@@ -1,15 +1,7 @@
import type { Result } from 'ts-results-es'
import { CommandType, EventType, Plugin } from '..';
import { AnyFunction } from '../types/utility';
import { Module } from '../types/core-modules';
export * from './functions';
export type _Module = {
meta: {
id: string,
absPath: string
}
name: string,
execute : Function
[key: PropertyKey]: unknown
}
export type VoidResult = Result<void, void>;

View File

@@ -21,6 +21,7 @@ export interface Emitter {
addListener(eventName: string | symbol, listener: AnyFunction): this;
removeListener(eventName: string | symbol, listener: AnyFunction): this;
emit(eventName: string | symbol, ...payload: any[]): boolean;
on(eventName: string | symbol, listener: AnyFunction): this
}

View File

@@ -75,6 +75,8 @@ async function composeRoot(
__add_container('@sern/logger', new __Services.DefaultLogging());
}
__add_container('@sern/errors', new __Services.DefaultErrorHandling());
__add_container('@sern/cron', {})
__add_container('@sern/modules', new Map())
//Build the container based on the callback provided by the user
conf.build(container as Container);
@@ -99,6 +101,7 @@ export async function makeDependencies (conf: ValidDependencyConfig) {
__add_container('@sern/logger', new __Services.DefaultLogging);
}
__add_container('@sern/errors', new __Services.DefaultErrorHandling());
__add_container('@sern/cron', {})
await useContainerRaw().ready();
} else {
await composeRoot(useContainerRaw(), conf);

View File

@@ -4,7 +4,7 @@ import * as __Services from '../structures/default-services';
* A semi-generic container that provides error handling, emitter, and module store.
* For the handler to operate correctly, The only user provided dependency needs to be @sern/client
*/
export function hasCallableMethod(obj: object, name: PropertyKey) {
function hasCallableMethod(obj: object, name: PropertyKey) {
//@ts-ignore
return Object.hasOwn(obj, name) && typeof obj[name] == 'function';
}

View File

@@ -3,7 +3,7 @@ import { existsSync } from 'fs';
import { readdir } from 'fs/promises';
import assert from 'node:assert';
import * as Id from './id'
import type { _Module } from './_internal';
import { Module } from '../types/core-modules';
export const parseCallsite = (site: string) => {
const pathobj = path.parse(site.replace(/file:\\?/, "")
@@ -38,11 +38,11 @@ export const shouldHandle = (pth: string, filenam: string) => {
export async function importModule<T>(absPath: string) {
let fileModule = await import(absPath);
let commandModule: _Module = fileModule.default;
let commandModule: Module = fileModule.default;
assert(commandModule , `No export @ ${absPath}. Forgot to ignore with "!"? (!${path.basename(absPath)})?`);
if ('default' in commandModule) {
commandModule = commandModule.default as _Module;
commandModule = commandModule.default as Module;
}
const p = path.parse(absPath)
commandModule.name ??= p.name; commandModule.description ??= "...";
@@ -51,21 +51,18 @@ export async function importModule<T>(absPath: string) {
id: Id.create(commandModule.name, commandModule.type),
absPath,
};
return { module: commandModule } as T;
return { module: commandModule as T };
}
export async function* readRecursive(dir: string): AsyncGenerator<string> {
const files = await readdir(dir, { withFileTypes: true, recursive: true });
const files = await readdir(dir, { recursive: true, withFileTypes: true });
for (const file of files) {
const fullPath = path.join(file.path, file.name);
const fullPath = path.join(file.parentPath, file.name);
if(!file.name.startsWith('!') && !file.isDirectory()) {
yield fullPath;
}
}
}
export const fmtFileName = (fileName: string) => path.parse(fileName).name;
export const filename = (p: string) => fmtFileName(path.basename(p));

View File

@@ -4,37 +4,35 @@ import type { AnyEventPlugin, } from '../types/core-plugin';
import type {
InputCommand,
InputEvent,
Module,
} from '../types/core-modules';
import { type _Module, partitionPlugins } from './_internal';
import { partitionPlugins } from './functions'
import type { Awaitable } from '../types/utility';
/**
* @since 1.0.0 The wrapper function to define command modules for sern
* @param mod
*/
export function commandModule(mod: InputCommand): _Module {
export function commandModule(mod: InputCommand): Module {
const [onEvent, plugins] = partitionPlugins(mod.plugins);
//@ts-ignore
return {
...mod,
onEvent,
plugins,
};
} as Module;
}
/**
* @since 1.0.0
* The wrapper function to define event modules for sern
* @param mod
*/
export function eventModule(mod: InputEvent): _Module {
export function eventModule(mod: InputEvent): Module {
const [onEvent, plugins] = partitionPlugins(mod.plugins);
//@ts-ignore
return {
...mod,
plugins,
onEvent,
};
} as Module;
}
/** Create event modules from discord.js client events,

View File

@@ -15,10 +15,6 @@ import {
} from 'rxjs';
import {
type VoidResult,
resultPayload,
isAutocomplete,
treeSearch,
_Module,
} from '../core/_internal';
import * as Id from '../core/id'
import type { Emitter, ErrorHandling, Logging } from '../core/interfaces';
@@ -42,7 +38,7 @@ function contextArgs(wrappable: Message | BaseInteraction, messageArgs?: string[
const args = ctx.isMessage() ? ['text', messageArgs!] : ['slash', ctx.options];
return [ctx, args] as [Context, Args];
}
import { resultPayload, isAutocomplete, treeSearch } from '../core/functions'
function intoPayload(module: Processed<Module>, ) {
return pipe(map(arrayifySource),
@@ -127,7 +123,7 @@ export function fmt(msg: string, prefix: string): string[] {
*/
export function createInteractionHandler<T extends Interaction>(
source: Observable<Interaction>,
mg: Map<string, _Module>, //TODO
mg: Map<string, Module>, //TODO
) {
return createGenericHandler<Interaction, T, Result<ReturnType<typeof createDispatcher>, void>>(
source,
@@ -135,7 +131,7 @@ export function createInteractionHandler<T extends Interaction>(
const possibleIds = Id.reconstruct(event);
let fullPaths= possibleIds
.map(id => mg.get(id))
.filter((id): id is _Module => id !== undefined);
.filter((id): id is Module => id !== undefined);
if(fullPaths.length == 0) {
return Err.EMPTY;
@@ -230,25 +226,6 @@ export function createResultResolver<
};
};
/**
* Calls a module's init plugins and checks for Err. If so, call { onStop } and
* ignore the module
*/
export function callInitPlugins<T extends Processed<Module>>(sernEmitter: Emitter) {
return concatMap(
createResultResolver({
createStream: args => from(args.module.plugins).pipe(callPlugin(args)),
onStop: (module: T) => {
sernEmitter.emit('module.register', resultPayload(PayloadType.Failure, module, SernError.PluginFailure));
},
onNext: (payload) => {
sernEmitter.emit('module.register', resultPayload(PayloadType.Success, payload.module));
return payload as { module: T; metadata: CommandMeta };
},
}),
);
}
/**
* Creates an executable task ( execute the command ) if all control plugins are successful
* @param onStop emits a failure response to the SernEmitter
@@ -264,19 +241,16 @@ export function makeModuleExecutor<
});
return createResultResolver({
onStop,
createStream: ({ args, module }) =>
from(module.onEvent)
.pipe(callPlugin(args)),
createStream: ({ args, module }) => from(module.onEvent).pipe(callPlugin(args)),
onNext,
})
}
export const handleCrash = (err: ErrorHandling,sernemitter: Emitter, log?: Logging) =>
pipe(
catchError(handleError(err, sernemitter, log)),
pipe(catchError(handleError(err, sernemitter, log)),
finalize(() => {
log?.info({
message: 'A stream closed or reached end of lifetime',
});
disposeAll(log);
}));
}))

View File

@@ -2,16 +2,11 @@ import type { Interaction } from 'discord.js';
import { mergeMap, merge, concatMap } from 'rxjs';
import { PayloadType } from '../core/structures/enums';
import { filterTap, sharedEventStream } from '../core/operators'
import {
isAutocomplete,
isCommand,
isMessageComponent,
isModal,
resultPayload,
} from '../core/_internal';
import { createInteractionHandler, executeModule, makeModuleExecutor } from './event-utils';
import type { DependencyList } from '../types/ioc';
import { SernError } from '../core/structures/enums'
import { isAutocomplete, isCommand, isMessageComponent, isModal, resultPayload, } from '../core/functions'
export function interactionHandler([emitter, err, log, client]: DependencyList) {
const interactionStream$ = sharedEventStream<Interaction>(client, 'interactionCreate');
const modules = new Map();
@@ -25,5 +20,5 @@ export function interactionHandler([emitter, err, log, client]: DependencyList)
.pipe(filterTap(e => emitter.emit('warning', resultPayload(PayloadType.Warning, undefined, e))),
concatMap(makeModuleExecutor(module =>
emitter.emit('module.activate', resultPayload(PayloadType.Failure, module, SernError.PluginFailure)))),
mergeMap(payload => executeModule(emitter, log, err, payload)));
mergeMap(payload => executeModule(emitter, log, err, payload)));
}

View File

@@ -20,16 +20,15 @@ function hasPrefix(prefix: string, content: string) {
}
export function messageHandler(
[emitter, err, log, client]: DependencyList,
[emitter, err, log, client, commands]: DependencyList,
defaultPrefix: string | undefined,
) {
if (!defaultPrefix) {
log?.debug({ message: 'No prefix found. message handler shutting down' });
return EMPTY;
}
const modules = new Map()
const messageStream$ = sharedEventStream<Message>(client, 'messageCreate');
const handle = createMessageHandler(messageStream$, defaultPrefix, modules);
const handle = createMessageHandler(messageStream$, defaultPrefix, commands);
const msgCommands$ = handle(isNonBot(defaultPrefix));

View File

@@ -23,11 +23,8 @@ const parseConfig = async (conf: Promise<PresenceResult>) => {
};
export const presenceHandler = (path: string, setPresence: SetPresence) => {
interface PresenceModule {
module: PresenceConfig<(keyof Dependencies)[]>
}
const presence = Files
.importModule<PresenceModule>(path)
.importModule<PresenceConfig<(keyof Dependencies)[]>>(path)
.then(({ module }) => {
//fetch services with the order preserved, passing it to the execute fn
const fetchedServices = Services(...module.inject ?? []);

View File

@@ -1,25 +0,0 @@
import { concat, first, fromEvent, ignoreElements, pipe, tap } from 'rxjs';
import { _Module } from '../core/_internal';
import { Logging } from '../core/interfaces';
import type { DependencyList } from '../types/ioc';
import { callInitPlugins } from './event-utils';
const once = (log: Logging | undefined) =>
pipe(tap(() => { log?.info({ message: "Waiting on discord client to be ready..." }) }),
first(),
ignoreElements())
export function readyHandler(
[sEmitter, , log, client]: DependencyList,
) {
//Todo: add module manager on on ready
const ready$ = fromEvent(client!, 'ready').pipe(once(log));
return concat(ready$).pipe(callInitPlugins(sEmitter)).subscribe();
// const validModuleType = module.type >= 0 && module.type <= 1 << 10;
// assert.ok(validModuleType,
// `Found ${module.name} at ${module.meta.fullPath}, which does not have a valid type`);
}

30
src/handlers/ready.ts Normal file
View File

@@ -0,0 +1,30 @@
import type { DependencyList } from '../types/ioc';
import * as Files from '../core/module-loading'
import { once } from 'events';
import { resultPayload } from '../core/functions';
import { PayloadType } from '..';
import { SernError } from '../core/structures/enums';
import { Module } from '../types/core-modules';
export default async function(dir: string, [sEmitter,, log, client, commands]: DependencyList) {
log?.info({ message: "Waiting on discord client to be ready..." })
await once(client, "ready");
log?.info({ message: "Client signaled ready, registering modules" });
for await (const path of Files.readRecursive(dir)) {
const { module } = await Files.importModule<Module>(path);
const validModuleType = module.type >= 0 && module.type <= 1 << 10;
if(!validModuleType) {
throw Error(`Found ${module.name} at ${module.meta.absPath}, which has an incorrect \`type\``);
}
for(const plugin of module.plugins) {
const res = await plugin.execute({ module, absPath: module.meta.absPath });
if(res.isErr()) {
sEmitter.emit('module.register', resultPayload(PayloadType.Failure, module, SernError.PluginFailure));
throw Error("Plugin failed with controller.stop()");
}
}
commands.set(module.meta.id, module);
sEmitter.emit('module.register', resultPayload(PayloadType.Success, module));
}
sEmitter.emit('modulesLoaded');
}

View File

@@ -1,14 +1,10 @@
import { ObservableInput, map, mergeAll } from 'rxjs';
import { EventType, SernError } from '../core/structures/enums';
import { callInitPlugins, eventDispatcher, handleCrash } from './event-utils'
import { eventDispatcher } from './event-utils'
import { Service } from '../core/ioc';
import type { DependencyList } from '../types/ioc';
import type { EventModule, Processed } from '../types/core-modules';
export function eventsHandler(
[emitter, err, log, client]: DependencyList,
//allPaths: ObservableInput<string>,
) {
export default function( [emitter, err, log, client]: DependencyList, eventDir: string) {
//code smell
const intoDispatcher = (e: { module: Processed<EventModule> }) => {
switch (e.module.type) {
@@ -18,6 +14,9 @@ export function eventsHandler(
return eventDispatcher(e.module, client);
case EventType.External:
return eventDispatcher(e.module, Service(e.module.emitter));
case EventType.Cron:
//@ts-ignore
return eventDispatcher(e.module, Service('@sern/cron'))
default:
throw Error(SernError.InvalidModuleType + ' while creating event handler');
}

View File

@@ -38,7 +38,7 @@ export type {
export type { Args, SlashOptions, Payload, SernEventsMapping } from './types/utility';
export type { Singleton, Transient, CoreDependencies } from './types/ioc';
export type { CoreDependencies } from './types/ioc';
export {
commandModule,

View File

@@ -2,8 +2,8 @@ import callsites from 'callsites';
import * as Files from './core/module-loading';
import { merge } from 'rxjs';
import { Services } from './core/ioc';
import { eventsHandler } from './handlers/user-defined-events';
import { readyHandler } from './handlers/ready-event';
import eventsHandler from './handlers/user-defined-events';
import ready from './handlers/ready';
import { messageHandler } from './handlers/message';
import { interactionHandler } from './handlers/interaction';
import { presenceHandler } from './handlers/presence';
@@ -11,9 +11,9 @@ import { Client } from 'discord.js';
import { handleCrash } from './handlers/event-utils';
interface Wrapper {
commands?: string;
commands: string;
defaultPrefix?: string;
events?: string;
events: string;
}
/**
* @since 1.0.0
@@ -27,37 +27,38 @@ interface Wrapper {
* })
* ```
*/
export function init(wrapper: Wrapper = { commands: "./dist/commands", events: "./dist/events" }) {
export function init(maybeWrapper: Wrapper = { commands: "./dist/commands", events: "./dist/events" }) {
const startTime = performance.now();
const dependencies = Services('@sern/emitter',
'@sern/errors',
'@sern/logger',
'@sern/client');
'@sern/client',
'@sern/modules');
const logger = dependencies[2],
errorHandler = dependencies[1];
if (wrapper.events !== undefined) {
eventsHandler(dependencies);
if (maybeWrapper.events !== undefined) {
eventsHandler(dependencies, maybeWrapper.events);
}
const initCallsite = callsites()[1].getFileName();
const presencePath = Files.shouldHandle(initCallsite!, "presence");
//Ready event: load all modules and when finished, time should be taken and logged
readyHandler(dependencies)
.add(() => {
logger?.info({ message: "Client signaled ready, registering modules" });
ready(maybeWrapper.commands, dependencies)
.then(() => {
const time = ((performance.now() - startTime) / 1000).toFixed(2);
dependencies[0].emit('modulesLoaded');
logger?.info({ message: `sern: registered in ${time} s`, });
if(presencePath.exists) {
const setPresence = async (p: any) => {
//@ts-ignore
return (dependencies[3] as Client).user?.setPresence(p);
}
presenceHandler(presencePath.path, setPresence).subscribe();
}
});
})
.catch(err => { throw err });
const messages$ = messageHandler(dependencies, wrapper.defaultPrefix);
const messages$ = messageHandler(dependencies, maybeWrapper.defaultPrefix);
const interactions$ = interactionHandler(dependencies);
// listening to the message stream and interaction stream
merge(messages$, interactions$).pipe(handleCrash(errorHandler, dependencies[0], logger)).subscribe();

View File

@@ -35,6 +35,10 @@ export interface Module {
onEvent: ControlPlugin[];
plugins: InitPlugin[];
description?: string;
meta: {
id: string;
absPath: string;
}
execute(...args: any[]): Awaitable<any>;
}
@@ -44,12 +48,18 @@ export interface SernEventCommand<T extends keyof SernEventsMapping = keyof Sern
type: EventType.Sern;
execute(...args: SernEventsMapping[T]): Awaitable<unknown>;
}
export interface ExternalEventCommand extends Module {
name?: string;
emitter: keyof Dependencies;
type: EventType.External;
execute(...args: unknown[]): Awaitable<unknown>;
}
export interface CronEventCommand extends Module {
name?: string;
type: EventType.Cron;
execute(...args: unknown[]): Awaitable<unknown>;
}
export interface ContextMenuUser extends Module {
type: CommandType.CtxUser;
@@ -127,7 +137,7 @@ export interface BothCommand extends Module {
execute: (ctx: Context, args: Args) => Awaitable<unknown>;
}
export type EventModule = DiscordEventCommand | SernEventCommand | ExternalEventCommand;
export type EventModule = DiscordEventCommand | SernEventCommand | ExternalEventCommand | CronEventCommand;
export type CommandModule =
| TextCommand
| SlashCommand
@@ -178,10 +188,10 @@ export interface SernAutocompleteData
}
type CommandModuleNoPlugins = {
[T in CommandType]: Omit<CommandModuleDefs[T], 'plugins' | 'onEvent'>;
[T in CommandType]: Omit<CommandModuleDefs[T], 'plugins' | 'onEvent' | 'meta'>;
};
type EventModulesNoPlugins = {
[T in EventType]: Omit<EventModuleDefs[T], 'plugins' | 'onEvent'>;
[T in EventType]: Omit<EventModuleDefs[T], 'plugins' | 'onEvent' | 'meta'>;
};
export type InputEvent = {

View File

@@ -1,29 +1,22 @@
import type { Container } from '../core/ioc/container';
import * as Contracts from '../core/interfaces';
import type { UnpackFunction } from './utility'
/**
* Type to annotate that something is a singleton.
* T is created once and lazily.
*/
export type Singleton<T> = () => T;
/**
* Type to annotate that something is transient.
* Every time this is called, a new object is created
*/
export type Transient<T> = () => () => T;
import type { Client } from 'discord.js'
import { Module } from './core-modules';
export type DependencyList = [
Contracts.Emitter,
Contracts.ErrorHandling,
Contracts.Logging | undefined,
Contracts.Emitter,
Client,
Map<string, Module>
];
export interface CoreDependencies {
'@sern/client': () => Contracts.Emitter;
'@sern/client': () => Client;
'@sern/emitter': () => Contracts.Emitter;
'@sern/errors': () => Contracts.ErrorHandling;
'@sern/logger'?: () => Contracts.Logging;
'@sern/modules': () => Map<string, Module>
}
export type DependencyFromKey<T extends keyof Dependencies> = Dependencies[T];

View File

@@ -4,7 +4,7 @@ import type { Module } from './core-modules';
export type Awaitable<T> = PromiseLike<T> | T;
export type AnyFunction = (...args: unknown[]) => unknown;
export type AnyFunction = (...args: never[]) => unknown;
// Thanks to @kelsny
type ParseType<T> = {

View File

@@ -235,12 +235,14 @@ __metadata:
resolution: "@sern/handler@workspace:."
dependencies:
"@faker-js/faker": ^8.0.1
"@types/node": ~18.17.11
"@types/node": ^20.0.0
"@types/node-cron": ^3.0.11
"@typescript-eslint/eslint-plugin": 5.58.0
"@typescript-eslint/parser": 5.59.1
callsites: ^3.1.0
discord.js: ^14.11.0
eslint: 8.39.0
node-cron: ^3.0.3
prettier: 2.8.8
rxjs: ^7.8.0
ts-results-es: ^4.1.0
@@ -255,6 +257,13 @@ __metadata:
languageName: node
linkType: hard
"@types/node-cron@npm:^3.0.11":
version: 3.0.11
resolution: "@types/node-cron@npm:3.0.11"
checksum: a73f69bcca52a5f3b1671cfb00a8e4a1d150d0aef36a611564a2f94e66b6981bade577e267ceeeca6fcee241768902d55eb8cf3a81f9ef4ed767a23112fdb16d
languageName: node
linkType: hard
"@types/node@npm:*":
version: 20.5.9
resolution: "@types/node@npm:20.5.9"
@@ -262,10 +271,12 @@ __metadata:
languageName: node
linkType: hard
"@types/node@npm:~18.17.11":
version: 18.17.19
resolution: "@types/node@npm:18.17.19"
checksum: 6ab47127cd7534511aa199550659d56b44e5a6dbec9df054d0cde279926b4d43f0e6438f92c8392b039ab4e2a85aa0f698b95926430aff860e23bfc36c96576c
"@types/node@npm:^20.0.0":
version: 20.12.12
resolution: "@types/node@npm:20.12.12"
dependencies:
undici-types: ~5.26.4
checksum: 5373983874b9af7c216e7ca5d26b32a8d9829c703a69f1e66f2113598b5be8582c0e009ca97369f1ec9a6282b3f92812208d06eb1e9fc3bd9b939b022303d042
languageName: node
linkType: hard
@@ -1198,6 +1209,15 @@ __metadata:
languageName: node
linkType: hard
"node-cron@npm:^3.0.3":
version: 3.0.3
resolution: "node-cron@npm:3.0.3"
dependencies:
uuid: 8.3.2
checksum: 351c37491ebf717d0ae69cc941465de118e5c2ef5d48bc3f87c98556241b060f100402c8a618c7b86f9f626b44756b20d8b5385b70e52f80716f21e55db0f1c5
languageName: node
linkType: hard
"once@npm:^1.3.0":
version: 1.4.0
resolution: "once@npm:1.4.0"
@@ -1506,6 +1526,13 @@ __metadata:
languageName: node
linkType: hard
"undici-types@npm:~5.26.4":
version: 5.26.5
resolution: "undici-types@npm:5.26.5"
checksum: 3192ef6f3fd5df652f2dc1cd782b49d6ff14dc98e5dced492aa8a8c65425227da5da6aafe22523c67f035a272c599bb89cfe803c1db6311e44bed3042fc25487
languageName: node
linkType: hard
"undici@npm:5.27.2":
version: 5.27.2
resolution: "undici@npm:5.27.2"
@@ -1524,6 +1551,15 @@ __metadata:
languageName: node
linkType: hard
"uuid@npm:8.3.2":
version: 8.3.2
resolution: "uuid@npm:8.3.2"
bin:
uuid: dist/bin/uuid
checksum: 5575a8a75c13120e2f10e6ddc801b2c7ed7d8f3c8ac22c7ed0c7b2ba6383ec0abda88c905085d630e251719e0777045ae3236f04c812184b7c765f63a70e58df
languageName: node
linkType: hard
"which@npm:^2.0.1":
version: 2.0.2
resolution: "which@npm:2.0.2"