Compare commits

..

8 Commits

Author SHA1 Message Date
Jacob Nguyen
31c2695cf8 fix+regres+errorhandling 2025-01-12 21:47:36 -06:00
Jacob Nguyen
bfe8d1d904 fix+regress 2025-01-12 21:18:03 -06:00
Jacob Nguyen
8073b32fb8 fixregres 2025-01-12 12:46:24 -06:00
Jacob Nguyen
bb80b24258 documentation+clean 2025-01-12 12:14:01 -06:00
Jacob Nguyen
b00c892611 document-task 2025-01-11 22:14:50 -06:00
Jacob Nguyen
fd4c4935a5 Merge branch 'main' into striprxjs 2025-01-11 22:03:59 -06:00
Jacob Nguyen
08ea11871d removerxjs 2025-01-11 22:03:24 -06:00
Jacob Nguyen
4d6a308df9 firstcommit 2025-01-11 13:53:45 -06:00
15 changed files with 420 additions and 630 deletions

View File

@@ -1,34 +1,5 @@
# Changelog
## [4.2.2](https://github.com/sern-handler/handler/compare/v4.2.1...v4.2.2) (2025-02-03)
### Bug Fixes
* faster autocomplete lookup ([#387](https://github.com/sern-handler/handler/issues/387)) ([974c30f](https://github.com/sern-handler/handler/commit/974c30fa6cccaae7b1c2c3246ffa9eecb6bc7bf9))
## [4.2.1](https://github.com/sern-handler/handler/compare/v4.2.0...v4.2.1) (2025-01-24)
### Bug Fixes
* context-interactions error ([#382](https://github.com/sern-handler/handler/issues/382)) ([a52ad27](https://github.com/sern-handler/handler/commit/a52ad270d843e92db5bf2049d07527eed59d428c))
## [4.2.0](https://github.com/sern-handler/handler/compare/v4.1.1...v4.2.0) (2025-01-18)
### Features
* 4.2.0 load multiple directories & `handleModuleErrors` ([#378](https://github.com/sern-handler/handler/issues/378)) ([f9e7eaf](https://github.com/sern-handler/handler/commit/f9e7eaf92d22b76d3d02a1bbe8324ca6813f48f8))
## [4.1.1](https://github.com/sern-handler/handler/compare/v4.1.0...v4.1.1) (2025-01-13)
### Bug Fixes
* remove rxjs ([#376](https://github.com/sern-handler/handler/issues/376)) ([59d08ef](https://github.com/sern-handler/handler/commit/59d08ef207c486ce1cf0aba267e6f862838e0dfb))
* This puts the light back into lightweight (\- 4.1 MB)
## [4.1.0](https://github.com/sern-handler/handler/compare/v4.0.3...v4.1.0) (2025-01-06)

View File

@@ -19,7 +19,7 @@
- Lightweight. Does a lot while being small.
- Latest features. Support for discord.js v14 and all of its interactions.
- Start quickly. Plug and play or customize to your liking.
- Works with [bun](https://bun.sh/) and [node](https://nodejs.org/en) out the box!
- works with [bun](https://bun.sh/) and [node](https://nodejs.org/en) out the box!
- Use it with TypeScript or JavaScript. CommonJS and ESM supported.
- Active and growing community, always here to help. [Join us](https://sern.dev/discord)
- Unleash its full potential with a powerful CLI and awesome plugins.
@@ -45,14 +45,14 @@ export default commandModule({
## 🤖 Bots Using sern
- [Community Bot](https://github.com/sern-handler/sern-community) - The community bot for our [Discord server](https://sern.dev/discord).
- [Vinci](https://github.com/SrIzan10/vinci) - The bot for Mara Turing.
- [Bask](https://github.com/baskbotml/bask) - Listen to your favorite artists on Discord.
- [Murayama](https://github.com/murayamabot/murayama) - :pepega:
- [Protector](https://github.com/GlitchApotamus/Protector) - Just a simple bot to help enhance a private Minecraft server.
- [SmokinWeed 💨](https://github.com/Peter-MJ-Parker/sern-bud) - A fun bot for a small, but growing server.
- [Man Nomic](https://github.com/jacoobes/man-nomic) - A simple information bot to provide information to the nomic-ai Discord community.
- [Linear-Discord](https://github.com/sern-handler/linear-discord) - Display and manage a linear dashboard.
- [Community Bot](https://github.com/sern-handler/sern-community), the community bot for our [discord server](https://sern.dev/discord).
- [Vinci](https://github.com/SrIzan10/vinci), the bot for Mara Turing.
- [Bask](https://github.com/baskbotml/bask), Listen your favorite artists on Discord.
- [Murayama](https://github.com/murayamabot/murayama), :pepega:
- [Protector](https://github.com/GlitchApotamus/Protector), Just a simple bot to help enhance a private minecraft server.
- [SmokinWeed 💨](https://github.com/Peter-MJ-Parker/sern-bud), A fun bot for a small - but growing - server.
- [Man Nomic](https://github.com/jacoobes/man-nomic), A simple information bot to provide information to the nomic-ai discord community.
- [Linear-Discord](https://github.com/sern-handler/linear-discord) Display and manage a linear dashboard.
## 💻 CLI
It is **highly encouraged** to use the [command line interface](https://github.com/sern-handler/cli) for your project. Don't forget to view it.

View File

@@ -1,7 +1,7 @@
{
"name": "@sern/handler",
"packageManager": "yarn@3.5.0",
"version": "4.2.2",
"version": "4.1.0",
"description": "A complete, customizable, typesafe, & reactive framework for discord bots.",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -20,7 +20,6 @@
"prepare": "tsc",
"pretty": "prettier --write .",
"tdd": "vitest",
"benchmark": "vitest bench",
"test": "vitest --run",
"analyze-imports": "npx depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg"
},

View File

@@ -6,12 +6,12 @@ import type {
MessageContextMenuCommandInteraction,
ModalSubmitInteraction,
UserContextMenuCommandInteraction,
AutocompleteInteraction,
AutocompleteInteraction
} from 'discord.js';
import { ApplicationCommandOptionType, InteractionType } from 'discord.js';
import { PluginType } from './structures/enums';
import assert from 'assert';
import type { Payload, UnpackedDependencies } from '../types/utility';
import path from 'node:path'
export const createSDT = (module: Module, deps: UnpackedDependencies, params: string|undefined) => {
return {
@@ -57,32 +57,52 @@ export function partitionPlugins<T,V>
return [controlPlugins, initPlugins] as [T[], V[]];
}
export const createLookupTable = (options: SernOptionsData[]): Map<string, SernAutocompleteData> => {
const table = new Map<string, SernAutocompleteData>();
_createLookupTable(table, options, "<parent>");
return table;
}
const _createLookupTable = (table: Map<string, SernAutocompleteData>, options: SernOptionsData[], parent: string) => {
for (const opt of options) {
const name = path.posix.join(parent, opt.name)
switch(opt.type) {
/**
* Uses an iterative DFS to check if an autocomplete node exists on the option tree
* @param iAutocomplete
* @param options
*/
export function treeSearch(
iAutocomplete: AutocompleteInteraction,
options: SernOptionsData[] | undefined,
): SernAutocompleteData & { parent?: string } | undefined {
if (options === undefined) return undefined;
//clone to prevent mutation of original command module
const _options = options.map(a => ({ ...a }));
const subcommands = new Set();
while (_options.length > 0) {
const cur = _options.pop()!;
switch (cur.type) {
case ApplicationCommandOptionType.Subcommand: {
_createLookupTable(table, opt.options ?? [], name);
} break;
subcommands.add(cur.name);
for (const option of cur.options ?? []) _options.push(option);
} break;
case ApplicationCommandOptionType.SubcommandGroup: {
_createLookupTable(table, opt.options ?? [], name);
} break;
for (const command of cur.options ?? []) _options.push(command);
} break;
default: {
if(Reflect.get(opt, 'autocomplete') === true) {
table.set(name, opt as SernAutocompleteData)
if ('autocomplete' in cur && cur.autocomplete) {
const choice = iAutocomplete.options.getFocused(true);
assert( 'command' in cur, 'No `command` property found for option ' + cur.name);
if (subcommands.size > 0) {
const parent = iAutocomplete.options.getSubcommand();
const parentAndOptionMatches =
subcommands.has(parent) && cur.name === choice.name;
if (parentAndOptionMatches) {
return { ...cur, parent };
}
} else {
if (cur.name === choice.name) {
return { ...cur, parent: undefined };
}
}
}
} break;
}
}
}
}
interface InteractionTypable {
type: InteractionType;
}
@@ -99,9 +119,6 @@ export function isMessageComponent(i: InteractionTypable): i is AnyMessageCompon
export function isCommand(i: InteractionTypable): i is AnyCommandInteraction {
return i.type === InteractionType.ApplicationCommand;
}
export function isContextCommand(i: AnyCommandInteraction): i is MessageContextMenuCommandInteraction | UserContextMenuCommandInteraction {
return i.isContextMenuCommand();
}
export function isAutocomplete(i: InteractionTypable): i is AutocompleteInteraction {
return i.type === InteractionType.ApplicationCommandAutocomplete;
}

View File

@@ -1,11 +1,10 @@
import type { Module, SernAutocompleteData } from '../types/core-modules'
import type { Module } from '../types/core-modules'
import { callPlugins, executeModule } from './event-utils';
import { SernError } from '../core/structures/enums'
import { createSDT, isAutocomplete, isCommand, isContextCommand, isMessageComponent, isModal, resultPayload } from '../core/functions'
import { createSDT, isAutocomplete, isCommand, isMessageComponent, isModal, resultPayload, treeSearch } from '../core/functions'
import type { UnpackedDependencies } from '../types/utility';
import * as Id from '../core/id'
import { Context } from '../core/structures/context';
import path from 'node:path';
@@ -30,28 +29,13 @@ export function interactionHandler(deps: UnpackedDependencies, defaultPrefix?: s
}
const { module, params } = modules.at(0)!;
let payload;
// handles autocomplete
if(isAutocomplete(event)) {
const lookupTable = module.locals['@sern/lookup-table'] as Map<string, SernAutocompleteData>
const subCommandGroup = event.options.getSubcommandGroup() ?? "",
subCommand = event.options.getSubcommand() ?? "",
option = event.options.getFocused(true),
fullPath = path.posix.join("<parent>", subCommandGroup, subCommand, option.name)
const resolvedModule = (lookupTable.get(fullPath)!.command) as Module
payload= { module: resolvedModule , //autocomplete is not a true "module" warning cast!
args: [event, createSDT(resolvedModule, deps, params)] };
// either CommandTypes Slash | ContextMessage | ContextUesr
//@ts-ignore stfu
const { command } = treeSearch(event, module.options);
payload= { module: command as Module, //autocomplete is not a true "module" warning cast!
args: [event, createSDT(command, deps, params)] };
} else if(isCommand(event)) {
const sdt = createSDT(module, deps, params)
// handle CommandType.CtxUser || CommandType.CtxMsg
if(isContextCommand(event)) {
payload= { module, args: [event, sdt] };
} else {
// handle CommandType.Slash || CommandType.Both
payload= { module, args: [Context.wrap(event, defaultPrefix), sdt] };
}
// handles modals or components
payload= { module, args: [Context.wrap(event, defaultPrefix), createSDT(module, deps, params)] };
} else if (isModal(event) || isMessageComponent(event)) {
payload= { module, args: [event, createSDT(module, deps, params)] }
} else {

View File

@@ -1,13 +1,12 @@
import * as Files from '../core/module-loading'
import { once } from 'node:events';
import { createLookupTable, resultPayload } from '../core/functions';
import { resultPayload } from '../core/functions';
import { CommandType } from '../core/structures/enums';
import { Module, SernOptionsData } from '../types/core-modules';
import type { UnpackedDependencies, Wrapper } from '../types/utility';
import { Module } from '../types/core-modules';
import type { UnpackedDependencies } from '../types/utility';
import { callInitPlugins } from './event-utils';
import { SernAutocompleteData } from '..';
export default async function(dirs: string | string[], deps : UnpackedDependencies) {
export default async function(dir: string, deps : UnpackedDependencies) {
const { '@sern/client': client,
'@sern/logger': log,
'@sern/emitter': sEmitter,
@@ -18,27 +17,16 @@ export default async function(dirs: string | string[], deps : UnpackedDependenci
// https://observablehq.com/@ehouais/multiple-promises-as-an-async-generator
// possibly optimize to concurrently import modules
const directories = Array.isArray(dirs) ? dirs : [dirs];
for (const dir of directories) {
for await (const path of Files.readRecursive(dir)) {
let { module } = await Files.importModule<Module>(path);
const validType = module.type >= CommandType.Text && module.type <= CommandType.ChannelSelect;
if(!validType) {
throw Error(`Found ${module.name} at ${module.meta.absPath}, which has incorrect \`type\``);
}
const resultModule = await callInitPlugins(module, deps, true);
if(module.type === CommandType.Both || module.type === CommandType.Slash) {
const options = (Reflect.get(module, 'options') ?? []) as SernOptionsData[];
const lookupTable = createLookupTable(options)
module.locals['@sern/lookup-table'] = lookupTable;
}
// FREEZE! no more writing!!
commands.set(resultModule.meta.id, Object.freeze(resultModule));
sEmitter.emit('module.register', resultPayload('success', resultModule));
for await (const path of Files.readRecursive(dir)) {
let { module } = await Files.importModule<Module>(path);
const validType = module.type >= CommandType.Text && module.type <= CommandType.ChannelSelect;
if(!validType) {
throw Error(`Found ${module.name} at ${module.meta.absPath}, which has incorrect \`type\``);
}
const resultModule = await callInitPlugins(module, deps, true);
// FREEZE! no more writing!!
commands.set(resultModule.meta.id, Object.freeze(resultModule));
sEmitter.emit('module.register', resultPayload('success', resultModule));
}
sEmitter.emit('modulesLoaded');
}

View File

@@ -1,21 +1,16 @@
import * as Files from '../core/module-loading'
import { UnpackedDependencies, Wrapper } from "../types/utility";
import { UnpackedDependencies } from "../types/utility";
import type { ScheduledTask } from "../types/core-modules";
import { relative } from "path";
import { fileURLToPath } from "url";
export const registerTasks = async (tasksDirs: string | string[], deps: UnpackedDependencies) => {
export const registerTasks = async (tasksPath: string, deps: UnpackedDependencies) => {
const taskManager = deps['@sern/scheduler']
const directories = Array.isArray(tasksDirs) ? tasksDirs : [tasksDirs];
for (const dir of directories) {
for await (const path of Files.readRecursive(dir)) {
let { module } = await Files.importModule<ScheduledTask>(path);
//module.name is assigned by Files.importModule<>
// the id created for the task is unique
const uuid = module.name+"/"+relative(dir,fileURLToPath(path))
taskManager.schedule(uuid, module, deps)
}
for await (const f of Files.readRecursive(tasksPath)) {
let { module } = await Files.importModule<ScheduledTask>(f);
//module.name is assigned by Files.importModule<>
// the id created for the task is unique
const uuid = module.name+"/"+relative(tasksPath,fileURLToPath(f))
taskManager.schedule(uuid, module, deps)
}
}

View File

@@ -1,6 +1,6 @@
import { EventType, SernError } from '../core/structures/enums';
import { callInitPlugins } from './event-utils'
import { EventModule } from '../types/core-modules';
import { EventModule, Module } from '../types/core-modules';
import * as Files from '../core/module-loading'
import type { UnpackedDependencies } from '../types/utility';
import type { Emitter } from '../core/interfaces';
@@ -10,16 +10,11 @@ import type { Wrapper } from '../'
export default async function(deps: UnpackedDependencies, wrapper: Wrapper) {
const eventModules: EventModule[] = [];
const eventDirs = Array.isArray(wrapper.events!) ? wrapper.events! : [wrapper.events!];
for (const dir of eventDirs) {
for await (const path of Files.readRecursive(dir)) {
let { module } = await Files.importModule<EventModule>(path);
await callInitPlugins(module, deps)
eventModules.push(module);
}
for await (const path of Files.readRecursive(wrapper.events!)) {
let { module } = await Files.importModule<Module>(path);
await callInitPlugins(module, deps)
eventModules.push(module as EventModule);
}
const logger = deps['@sern/logger'], report = deps['@sern/emitter'];
for (const module of eventModules) {
let source: Emitter;

View File

@@ -53,3 +53,20 @@ export * from './core/plugin';
export { CommandType, PluginType, PayloadType, EventType } from './core/structures/enums';
export { Context } from './core/structures/context';
export { type CoreDependencies, makeDependencies, single, transient, Service, Services } from './core/ioc';
import type { Container } from '@sern/ioc';
/**
* @deprecated This old signature will be incompatible with future versions of sern >= 4.0.0. See {@link makeDependencies}
* @example
* ```ts
* To switch your old code:
await makeDependencies(({ add }) => {
add('@sern/client', new Client())
})
* ```
*/
export interface DependencyConfiguration {
build: (root: Container) => Container;
}

View File

@@ -11,7 +11,7 @@ import ready from './handlers/ready';
import { interactionHandler } from './handlers/interaction';
import { messageHandler } from './handlers/message'
import { presenceHandler } from './handlers/presence';
import type { Payload, UnpackedDependencies, Wrapper } from './types/utility';
import { UnpackedDependencies, Wrapper } from './types/utility';
import type { Presence} from './core/presences';
import { registerTasks } from './handlers/tasks';
@@ -32,6 +32,7 @@ import { registerTasks } from './handlers/tasks';
export function init(maybeWrapper: Wrapper = { commands: "./dist/commands" }) {
const startTime = performance.now();
const deps = useContainerRaw().deps<UnpackedDependencies>();
if (maybeWrapper.events !== undefined) {
eventsHandler(deps, maybeWrapper)
.then(() => {
@@ -41,22 +42,6 @@ export function init(maybeWrapper: Wrapper = { commands: "./dist/commands" }) {
deps['@sern/logger']?.info({ message: "No events registered" });
}
// autohandle errors that occur in modules.
// convenient for rapid iteration
if(maybeWrapper.handleModuleErrors) {
if(!deps['@sern/logger']) {
throw Error('A logger is required to handleModuleErrors.\n A default logger is already supplied!');
}
deps['@sern/logger']?.info({ 'message': 'handleModuleErrors enabled' })
deps['@sern/emitter'].addListener('error', (payload: Payload) => {
if(payload.type === 'failure') {
deps['@sern/logger']?.error({ message: payload.reason })
} else {
deps['@sern/logger']?.warning({ message: "error event should only have payloads of 'failure'" });
}
})
}
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
@@ -75,6 +60,10 @@ export function init(maybeWrapper: Wrapper = { commands: "./dist/commands" }) {
}
})
.catch(err => { throw err });
//const messages$ = messageHandler(deps, maybeWrapper.defaultPrefix);
interactionHandler(deps, maybeWrapper.defaultPrefix);
messageHandler(deps, maybeWrapper.defaultPrefix)
// listening to the message stream and interaction stream
//merge(messages$, interactions$).subscribe();
}

View File

@@ -61,7 +61,7 @@ import { Awaitable, SernEventsMapping, UnpackedDependencies, Dictionary } from '
*
* @see {@link CommandControlPlugin} for plugin implementation
* @see {@link CommandType} for available command types
* @see {@link Dependencies} for [dependency injection](https://sern.dev/v4/reference/dependencies/) interface
* @see {@link Dependencies} for dependency injection interface
*/
export type SDT = {
/**
@@ -114,10 +114,6 @@ export type SDT = {
export type Processed<T> = T & { name: string; description: string };
/**
* @since 1.0.0
*/
export interface Module {
type: CommandType | EventType;
name?: string;
@@ -200,18 +196,13 @@ export interface Module {
execute(...args: any[]): Awaitable<any>;
}
/**
* @since 1.0.0
*/
export interface SernEventCommand<T extends keyof SernEventsMapping = keyof SernEventsMapping>
extends Module {
name?: T;
type: EventType.Sern;
execute(...args: SernEventsMapping[T]): Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface ExternalEventCommand extends Module {
name?: string;
emitter: keyof Dependencies;
@@ -219,121 +210,83 @@ export interface ExternalEventCommand extends Module {
execute(...args: unknown[]): Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface ContextMenuUser extends Module {
type: CommandType.CtxUser;
execute: (ctx: UserContextMenuCommandInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface ContextMenuMsg extends Module {
type: CommandType.CtxMsg;
execute: (ctx: MessageContextMenuCommandInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface ButtonCommand extends Module {
type: CommandType.Button;
execute: (ctx: ButtonInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface StringSelectCommand extends Module {
type: CommandType.StringSelect;
execute: (ctx: StringSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface ChannelSelectCommand extends Module {
type: CommandType.ChannelSelect;
execute: (ctx: ChannelSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface RoleSelectCommand extends Module {
type: CommandType.RoleSelect;
execute: (ctx: RoleSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface MentionableSelectCommand extends Module {
type: CommandType.MentionableSelect;
execute: (ctx: MentionableSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface UserSelectCommand extends Module {
type: CommandType.UserSelect;
execute: (ctx: UserSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface ModalSubmitCommand extends Module {
type: CommandType.Modal;
execute: (ctx: ModalSubmitInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface AutocompleteCommand {
onEvent?: ControlPlugin[];
execute: (ctx: AutocompleteInteraction, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export interface DiscordEventCommand<T extends keyof ClientEvents = keyof ClientEvents>
extends Module {
name?: T;
type: EventType.Discord;
execute(...args: ClientEvents[T]): Awaitable<unknown>;
}
/**
* @since 1.0.0
* @see @link {commandModule} to create a text command
*/
export interface TextCommand extends Module {
type: CommandType.Text;
execute: (ctx: Context & { get options(): string[] }, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
* @see @link {commandModule} to create a slash command
*/
export interface SlashCommand extends Module {
type: CommandType.Slash;
description: string;
options?: SernOptionsData[];
execute: (ctx: Context & { get options(): ChatInputCommandInteraction['options']}, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
* @see @link {commandModule} to create a both command
*/
export interface BothCommand extends Module {
type: CommandType.Both;
description: string;
options?: SernOptionsData[];
execute: (ctx: Context, tbd: SDT) => Awaitable<unknown>;
}
/**
* @since 1.0.0
*/
export type EventModule = DiscordEventCommand | SernEventCommand | ExternalEventCommand;
/**
* @since 1.0.0
*/
export type EventModule = DiscordEventCommand | SernEventCommand | ExternalEventCommand;
export type CommandModule =
| TextCommand
| SlashCommand
@@ -402,7 +355,6 @@ export type InputCommand = {
}[CommandType];
/**
* @see @link {https://sern.dev/v4/reference/autocomplete/}
* Type that replaces autocomplete with {@link SernAutocompleteData}
*/
export type SernOptionsData =
@@ -422,9 +374,7 @@ export interface SernSubCommandGroupData extends BaseApplicationCommandOptionsDa
options?: SernSubCommandData[];
}
/**
* @since 4.0.0
*/
export interface ScheduledTaskContext {
/**
@@ -448,9 +398,7 @@ interface TaskAttrs {
*/
deps: UnpackedDependencies
}
/**
* @since 4.0.0
*/
export interface ScheduledTask {
name?: string;
trigger: string | Date;

View File

@@ -26,65 +26,9 @@ export type UnpackedDependencies = {
export type ReplyOptions = string | Omit<InteractionReplyOptions, 'fetchReply'> | MessageReplyOptions;
/**
* @interface Wrapper
* @description Configuration interface for the sern framework. This interface defines
* the structure for configuring essential framework features including command handling,
* event management, and task scheduling.
*/
export interface Wrapper {
/**
* @property {string|string[]} commands
* @description Specifies the directory path where command modules are located.
* This is a required property that tells sern where to find and load command files.
* The path should be relative to the project root. If given an array, each directory is loaded in order
* they were declared. Order of modules in each directory is not guaranteed
*
* @example
* commands: ["./dist/commands"]
*/
commands: string | string[];
/**
* @property {boolean} [handleModuleErrors]
* @description Optional flag to enable automatic error handling for modules.
* When enabled, sern will automatically catch and handle errors that occur
* during module execution, preventing crashes and providing error logging.
*
* @default false
*/
handleModuleErrors?: boolean;
/**
* @property {string} [defaultPrefix]
* @description Optional prefix for text commands. This prefix will be used
* to identify text commands in messages. If not specified, text commands {@link CommandType.Text}
* will be disabled.
*
* @example
* defaultPrefix: "?"
*/
commands: string;
defaultPrefix?: string;
/**
* @property {string|string[]} [events]
* @description Optional directory path where event modules are located.
* If provided, Sern will automatically register and handle events from
* modules in this directory. The path should be relative to the project root.
* If given an array, each directory is loaded in order they were declared.
* Order of modules in each directory is not guaranteed.
*
* @example
* events: ["./dist/events"]
*/
events?: string | string[];
/**
* @property {string|string[]} [tasks]
* @description Optional directory path where scheduled task modules are located.
* If provided, Sern will automatically register and handle scheduled tasks
* from modules in this directory. The path should be relative to the project root.
* If given an array, each directory is loaded in order they were declared.
* Order of modules in each directory is not guaranteed.
*
* @example
* tasks: ["./dist/tasks"]
*/
tasks?: string | string[];
events?: string;
tasks?: string;
}

View File

@@ -1,84 +0,0 @@
import { describe } from 'node:test'
import { bench } from 'vitest'
import { SernAutocompleteData, SernOptionsData } from '../src'
import { createRandomChoice } from './setup/util'
import { ApplicationCommandOptionType, AutocompleteFocusedOption, AutocompleteInteraction } from 'discord.js'
import { createLookupTable } from '../src/core/functions'
import assert from 'node:assert'
/**
* Uses an iterative DFS to check if an autocomplete node exists on the option tree
* This is the old internal method that sern used to resolve autocomplete
* @param iAutocomplete
* @param options
*/
function treeSearch(
choice: AutocompleteFocusedOption,
parent: string|undefined,
options: SernOptionsData[] | undefined,
): SernAutocompleteData & { parent?: string } | undefined {
if (options === undefined) return undefined;
//clone to prevent mutation of original command module
const _options = options.map(a => ({ ...a }));
const subcommands = new Set();
while (_options.length > 0) {
const cur = _options.pop()!;
switch (cur.type) {
case ApplicationCommandOptionType.Subcommand: {
subcommands.add(cur.name);
for (const option of cur.options ?? []) _options.push(option);
} break;
case ApplicationCommandOptionType.SubcommandGroup: {
for (const command of cur.options ?? []) _options.push(command);
} break;
default: {
if ('autocomplete' in cur && cur.autocomplete) {
assert( 'command' in cur, 'No `command` property found for option ' + cur.name);
if (subcommands.size > 0) {
const parentAndOptionMatches =
subcommands.has(parent) && cur.name === choice.name;
if (parentAndOptionMatches) {
return { ...cur, parent };
}
} else {
if (cur.name === choice.name) {
return { ...cur, parent: undefined };
}
}
}
} break;
}
}
}
const options: SernOptionsData[] = [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'autocomplete',
description: 'here',
autocomplete: true,
command: { onEvent: [], execute: () => {} },
},
]
const table = createLookupTable(options)
describe('autocomplete lookup', () => {
bench('lookup table', () => {
table.get('<parent>/autocomplete')
}, { time: 500 })
bench('naive treeSearch', () => {
treeSearch({ focused: true,
name: 'autocomplete',
value: 'autocomplete',
type: ApplicationCommandOptionType.String }, undefined, options)
}, { time: 500 })
})

View File

@@ -1,17 +1,29 @@
//@ts-nocheck
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PluginType, SernOptionsData, controller } from '../../src/index';
import { createLookupTable, partitionPlugins, treeSearch } from '../../src/core/functions';
import { partitionPlugins, treeSearch } from '../../src/core/functions';
import { faker } from '@faker-js/faker';
import { ApplicationCommandOptionType, AutocompleteInteraction } from 'discord.js';
import { createRandomChoice, createRandomPlugins } from '../setup/util';
describe('functions', () => {
afterEach(() => {
vi.clearAllMocks();
});
function createRandomPlugins(len: number) {
const random = () => Math.floor(Math.random() * 2) + 1; // 1 or 2, plugin enum
return Array.from({ length: len }, () => ({
type: random(),
execute: () => (random() === 1 ? controller.next() : controller.stop()),
}));
}
function createRandomChoice() {
return {
type: faker.number.int({ min: 1, max: 11 }),
name: faker.word.noun(),
description: faker.word.adjective(),
};
}
it('should partition plugins correctly', () => {
const plugins = createRandomPlugins(100);
const [onEvent, init] = partitionPlugins(plugins);
@@ -20,34 +32,127 @@ describe('functions', () => {
for (const el of init) expect(el.type).to.equal(PluginType.Init);
});
describe('autocomplete', ( ) => {
it('should tree search options tree depth 1', () => {
it('should tree search options tree depth 1', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('autocomplete');
const options: SernOptionsData[] = [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'autocomplete',
description: 'here',
autocomplete: true,
command: { onEvent: [], execute: vi.fn() },
},
];
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'autocomplete',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('autocomplete');
expect(result.command).to.be.not.undefined;
}),
it('should tree search depth 2', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'autocomplete',
description: 'here',
autocomplete: true,
command: { onEvent: [], execute: vi.fn() },
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
];
const table = createLookupTable(options)
const result = table.get('<parent>/autocomplete')
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('autocomplete');
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
}),
it('should tree search depth 2', () => {
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
});
it('should tree search depth n > 2', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
createRandomChoice(),
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('should correctly resolve suboption of the same name given two subcommands ', () => {
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
@@ -62,233 +167,173 @@ describe('functions', () => {
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${subcommandName}/nested`)
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('two subcommands with an option of the same name', () => {
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
let autocmpInteraction2 = new AutocompleteInteraction('nested');
autocmpInteraction2.options.getSubcommand.mockReturnValue(subcommandName + 'a');
autocmpInteraction2.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result2 = treeSearch(autocmpInteraction2, options);
expect(result2).toBeTruthy();
expect(result2?.name).toEqual('nested');
});
it('simulates autocomplete typing and resolution', () => {
const subcommandName = faker.string.alpha();
const optionName = faker.word.noun();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
},
},
],
},
],
},
];
let accumulator = '';
let result: unknown;
for (const char of optionName) {
accumulator += char;
const autocomplete = new AutocompleteInteraction(accumulator);
autocomplete.options.getSubcommand.mockReturnValue(subcommandName);
autocomplete.options.getFocused.mockReturnValue({
name: accumulator,
value: faker.string.alpha(),
focused: true,
});
it('should tree search depth n > 2', () => {
const subgroupName = faker.string.alpha()
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: subgroupName,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
createRandomChoice(),
],
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${subgroupName}/${subcommandName}/nested`)
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('should correctly resolve suboption of the same name given two subcommands ', () => {
const subcommandName = faker.string.alpha();
const groupname = faker.string.alpha()
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: groupname,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${groupname}/${subcommandName}/nested`);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('two subcommands with an option of the same name', () => {
const groupName = faker.string.alpha()
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: groupName,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'anothera',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${groupName}/${subcommandName}/nested`);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('simulates autocomplete typing and resolution', () => {
const subcommandGroupName = faker.string.alpha()
const subcommandName = faker.string.alpha();
const optionName = faker.word.noun();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: subcommandGroupName,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
},
},
],
},
],
},
];
let accumulator = '';
let result: unknown;
const table = createLookupTable(options)
for (const char of optionName) {
accumulator += char;
const focusedValue = {
name: accumulator,
value: faker.string.alpha(),
focused: true,
};
result = table.get(`<parent>/${subcommandGroupName}/${subcommandName}/${focusedValue.name}` );
}
expect(result).toBeTruthy();
});
})
result = treeSearch(autocomplete, options);
}
expect(result).toBeTruthy();
});
});

View File

@@ -28,21 +28,3 @@ export function createRandomModule(plugins: any[]): Processed<Module> {
execute: vi.fn(),
};
}
export function createRandomChoice() {
return {
type: faker.number.int({ min: 1, max: 11 }),
name: faker.word.noun(),
description: faker.word.adjective(),
};
}
export function createRandomPlugins(len: number) {
const random = () => Math.floor(Math.random() * 2) + 1; // 1 or 2, plugin enum
return Array.from({ length: len }, () => ({
type: random(),
execute: () => (random() === 1 ? controller.next() : controller.stop()),
}));
}