Compare commits

..

70 Commits

Author SHA1 Message Date
Jacob Nguyen
8ec59a3443 fix up some bugs and debugging 2024-08-11 01:48:24 -05:00
Jacob Nguyen
3e668090d8 initial prototype 2024-08-11 01:27:49 -05:00
xxDeveloper
3755b95b1a chore: Update LICENSE year (#365) 2024-08-06 10:56:25 -05:00
Jacob Nguyen
06807ea77f Update README.md 2024-07-19 01:32:22 -05:00
github-actions[bot]
3fd3f1c236 chore(main): release 4.0.1 (#364)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-07-18 22:55:59 -05:00
Peter-MJ-Parker
92623d2914 fix: add SDT typings to autocomplete commands (#363) 2024-07-18 22:54:23 -05:00
github-actions[bot]
a91f260a86 chore(main): release 4.0.0 (#362)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-07-18 17:01:47 -05:00
Jacob Nguyen
dda0e3395b chore: release 4.0.0
Release-As: 4.0.0
2024-07-18 17:00:33 -05:00
Jacob Nguyen
9a8904f5ae feat: v4 (#361)
* step 1

* Refactorings

* command modules do not depend on anything but itself

* tearing it up

* Remove module store, manager, and Intializable type

* consolidate interfaces in single file

* consolidate default services in single file

* TEAR IT UP

* fix text compile

* the end of sern init??

* Presence namespaced types removed

* internal namespace

* clean up dependencies

* fix test

* fix circular dependency

* still broken but progress

* remove barrel for core/structs

* reffactor

* refactor allat

* more refactoring

* prototyping linking static handler

* cleanup tests, codegen, and importing handler

* some refactor

* generify partition

* for now copy paste new ioc system

* removeiti

* fdsfD

* ensure container is init'd

* fix absPath gen

* working on bun compat

* refactor and clean up and reenter v3 module loading

* dsfsd

* refactor, add cron types, reinstante module loader

* ready handler revamped so much cleaner

* fdssdf

* refactor deps list

* add more tests, polish up ioc

* up to speed with event modules

* i think cron works

* cron works now, poc

* ksdjkldsfld

* updating ioc api, experimenting with cron

* save b4 thunder and lightning

* plugin data reduction & args changes

* freeze module after plugins, updateModule, and more

* simplify plugin args and prepare for reduction among plugins

* add deps to plugin calls and execute

* plugin system loking better, tbd type

* porg

* initplugins inject deps, inconspicuos

* fix faiklling test

* fix initPlugins not reassigning

* parsingParams kinda

* proper mapping

* dynamic customIds

* handling customId params working

* testing n shi

* inlineinignsd

* consolidate fmt

* once on eventModules

* refact,simplf

* readd vitest and Asset fn

* fix typings

* assets fn complete

* more intuitive context.options and Asset typings

* add init hooks not firing

* -file,-updateModule,publish?

* fix: ioc deps not created correctly

* documentation, add json for Asset

* remove asset

* ss

* finish ioc transition

* nvm, now i did

* s

* update locals api, docs, tests

* fix tests

* fix up tests and cleanup

* fix

* Update src/core/functions.ts

Co-authored-by: Evo <85353424+EvolutionX-10@users.noreply.github.com>

* better documentation

* temp fix

* namespace presence types again

* revising cron modules and better error messages

* scheduler ids

* more descriptive errors

* refactor to not type leak and job cancellation

* refactor n better signatures for task scheduler

* documentation

* fix swap not accepting functions

* change task signature

---------

Co-authored-by: Evo <85353424+EvolutionX-10@users.noreply.github.com>
2024-07-18 16:54:55 -05:00
Duro
04c4625bfa docs: change @param wrapper -> @param maybeWrapper for Sern.init (#360)
change `@param wrapper` -> `@param maybeWrapper` for typedoc fix
2024-05-14 21:52:24 -05:00
Jacob Nguyen
91b3768e37 bump tsresults 2024-03-21 10:46:19 -05:00
renovate[bot]
d6f49d1d97 chore(deps): update actions/setup-node digest to 1a4442c (#354)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2024-03-21 10:35:43 -05:00
renovate[bot]
8ecd30cf18 chore(deps): update actions/checkout digest to f43a0e5 (#353)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2024-03-19 10:22:39 -05:00
github-actions[bot]
a19edaf883 chore(main): release 3.3.4 (#359)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-18 01:57:13 -05:00
Jacob Nguyen
90e55dfa14 fix: sern emitter err (#358)
* prep for fix

* fix ? (not tested

* fix error event not emitting payload
2024-03-18 01:47:14 -05:00
github-actions[bot]
2106522812 chore(main): release 3.3.3 (#357)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-02-24 18:35:24 -06:00
Jacob Nguyen
ce8c4bf649 fix: typings and cleanup (#356)
* fix typings and cleanup

* import type

* rm unused import
2024-02-18 09:34:53 -06:00
jacob
e89b918390 dfs 2024-02-17 11:40:19 -06:00
jacob
f8b69ae542 stuff 2024-02-17 11:38:37 -06:00
Jacob Nguyen
48f9f6ec16 fix: rm deprecated class modules, clean up, rm indirection (#355)
* refactor: rm deprecations, clean up, rm indirection

* fix: singleton init not being fired when inserting function

* refactor and generic internal add

* deprecate a few things that i impusively added lol
2024-02-17 11:35:53 -06:00
jacob
86ebb221ed deprecate a few things that i impusively added lol 2024-02-15 12:36:00 -06:00
jacob
4efdbb21fb refactor and generic internal add 2024-02-15 12:26:34 -06:00
jacob
07b11b357b fix: singleton init not being fired when inserting function 2024-02-14 15:58:50 -06:00
jacob
ac7f47c590 refactor: rm deprecations, clean up, rm indirection 2024-02-12 21:47:35 -06:00
45cbda7b42 refactor: cleanup (#348)
* some wip code

Co-authored-by: Jacob Nguyen <jacoobes@users.noreply.github.com>

* general idea

* style

* making shrimple truly optional

* got optional localizer working

* proposing api notation?

* prepare for localization map

* add localsFor

* merge some internals

* boss call

* add test for init functionality

* add documentation

* inline and cleanup

* feat: logging for experimental json loading

* loosen typings

* dev workflow and cleaning up comments

* cleaning up a bit more

* rename Localizer -> Localization

* more documentation, change dir for default localizer

* some tests

* "

* move stuff, refactor, deprecate

* yarnb

* Update index.ts

---------

Co-authored-by: Jacob Nguyen <jacoobes@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
Co-authored-by: jacob <jacoobes@sern.dev>
2024-02-09 17:46:16 -06:00
Jacob Nguyen
5cad432589 Update README.md 2024-01-08 14:31:49 -06:00
Jacob Nguyen
044a10dace Update README.md 2024-01-08 14:29:49 -06:00
github-actions[bot]
9d5c6c714f chore(main): release 3.3.2 (#352)
* chore(main): release 3.3.2

* Update CHANGELOG.md

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2024-01-08 12:16:52 -06:00
Jacob Nguyen
4f2387119a fix: presence feature not workign on cjs applications (#351)
* start fix

* fix on unix

* better solution
2024-01-08 12:11:27 -06:00
Jacob Nguyen
a6fa4e3dcb fix docs build 2024-01-07 15:38:22 -06:00
github-actions[bot]
c281832db2 chore(main): release 3.3.1 (#350)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2024-01-07 15:27:43 -06:00
Jacob Nguyen
a359f73fa2 fix: crashing when slash command is used as text command (#349)
* progress on fix

* fix: ids
2024-01-07 15:26:08 -06:00
655bb8d358 revert: the last commit 2024-01-05 20:47:25 +01:00
e8d5029834 chore: update fortnite file 2024-01-05 20:46:38 +01:00
Jacob Nguyen
b0399f9507 refactor: minor (#347)
* some refactoring

* accidental merge

* refactor: ensure all asserts have error message to avoid cryptic messages

* general refactoring

* move controller to create-plugin
2024-01-02 13:04:59 -06:00
renovate[bot]
b962dae36c chore(deps): update actions/setup-node digest to 1a4442c (#314)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-12-27 11:40:18 -06:00
github-actions[bot]
c73cf96cb2 chore(main): release 3.3.0 (#346)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-12-27 11:13:11 -06:00
Jacob Nguyen
7458befe8a feat: presence (#345)
* presence

* from event presence and refactoring

* refine presence api

* add tests and more comments

* sss

---------

Co-authored-by: SrIzan10 <66965250+SrIzan10@users.noreply.github.com>
2023-12-27 11:11:32 -06:00
Jacob Nguyen
efe49391e8 Update README.md 2023-12-27 01:51:41 -06:00
Jacob Nguyen
3140f80c10 Update README.md 2023-12-27 01:46:55 -06:00
github-actions[bot]
504cdee7b2 chore(main): release 3.2.1 (#344)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-12-21 12:49:42 -06:00
Jacob Nguyen
c7661f272c chore: bump version 2023-12-21 12:47:24 -06:00
Jacob Nguyen
daac37c288 fix: logger swap failing 2023-12-21 12:47:02 -06:00
ysf
a579e272d0 revolutionary (#342) 2023-12-15 17:03:23 -06:00
github-actions[bot]
2051aa1ac0 chore(main): release 3.2.0 (#341)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-12-15 16:23:39 -06:00
Jacob Nguyen
237c8537c6 chore: release 3.2.0
Release-As: 3.2.0
2023-12-15 16:19:38 -06:00
Jacob Nguyen
77fb00d386 feat/abstractiti (#340)
* progress on better error handling

* wiring onError callback through module loader and resolver

* fix error callbacks not being stored

* update onError to be record

* type alias

* wiring

* seems to work

* update error handling contract and wire more

* add command error builder

* fix merge

* progress on error handling

* naive onError handling, not tested

* progres

* proress

* progress on abstracting away iti

* seems to work

* fix tests

* better typings

* add doc

* abstracting iti

* remove onerror for this pr

* feat: better way to add dependencies

* fix tests
2023-12-15 16:09:13 -06:00
renovate[bot]
89f6bbb975 chore(deps): update google-github-actions/release-please-action digest to db8f2c6 (#339)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-11-21 10:18:07 -06:00
github-actions[bot]
8ef4ee87e9 chore(main): release 3.1.1 (#338)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-11-05 21:25:18 -06:00
Neo
fd39858636 fix: queuing events (#332) @Benzo-Fury (#333)
fix: queuing events

Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-11-05 21:23:27 -06:00
Jacob Nguyen
132b625070 refactor: rm redudant fns and formatting 2023-11-04 16:57:13 -05:00
renovate[bot]
03439fec43 chore(deps): update google-github-actions/release-please-action digest to 4c5670f (#336)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-10-21 22:30:40 -05:00
Jacob Nguyen
fc87e99ed0 Update README.md 2023-09-09 01:08:16 -05:00
renovate[bot]
a08541a8e7 chore(deps): update actions/checkout digest to f43a0e5 (#329)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-04 17:06:17 -05:00
renovate[bot]
8bd5eb4949 chore(deps): lock file maintenance (#293)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-09-04 17:05:33 -05:00
github-actions[bot]
e1059f93f7 chore(main): release 3.1.0 (#330)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-09-04 15:43:47 -05:00
renovate[bot]
800531453f chore(deps): pin dependencies (#311)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-08-22 11:37:22 -05:00
Jacob Nguyen
c9f2d75665 deprecate: mode (#325)
* test: add tests for context

* deprecate: mode

* revert docs for deprecated option
2023-08-19 07:07:47 +05:30
Jacob Nguyen
e59e0b9d40 test: add tests for context (#324) 2023-08-18 10:46:46 -05:00
Jacob Nguyen
26ccd118ff feat: dispose hooks (deprecate useContainerRaw) (#323)
* feat: dispose hooks

* build: unminify, add source map, deprecate useContainerRaw

* fix regression of context and fix tsup build
2023-08-17 12:51:24 -05:00
Jacob Nguyen
4b97d86908 chore: upgrade ts-results-es (#322) 2023-08-13 10:55:39 -05:00
xxDeveloper
b1c82448bd chore: Create FUNDING.yml (#321)
* chore: Create FUNDING.yml

* Rename FUNDING.yml to .github/FUNDING.yml
2023-08-08 10:29:40 +03:00
Jacob Nguyen
d80081384a Update README.md 2023-08-07 17:23:58 -05:00
mina
50253ca322 feat: add guaranteed channelId and userId getters to Context (#320) 2023-08-06 15:28:38 -05:00
github-actions[bot]
215aca2f46 chore(main): release 3.0.2 (#319)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-08-06 10:45:17 -05:00
Jacob Nguyen
a7f5ea269f fix: invalid id for cts, mts, cjs, mjs files, node paths (#318)
* better error messages

* fix: invalid id for cts, mts, cjs, mjs files
2023-08-06 10:43:34 -05:00
Jacob Nguyen
52d6368440 Delete codeql-analysis.yml 2023-08-06 00:36:50 -05:00
Jacob Nguyen
1e723a4154 Update npm-publish.yml 2023-08-06 00:34:43 -05:00
Jacob Nguyen
5fe13f43d2 better npm-publish.yml 2023-08-06 00:33:40 -05:00
Jacob Nguyen
ab9d39306a Create test.yml (#317)
* Create test.yml

* Update test.yml

* Update test.yml
2023-08-06 00:29:43 -05:00
93 changed files with 3106 additions and 6226 deletions

View File

@@ -1,450 +0,0 @@
/** @type {import('dependency-cruiser').IConfiguration} */
module.exports = {
forbidden: [
/* rules from the 'recommended' preset: */
{
name: 'no-circular',
severity: 'warn',
comment:
'This dependency is part of a circular relationship. You might want to revise ' +
'your solution (i.e. use dependency inversion, make sure the modules have a single responsibility) ',
from: {},
to: {
circular: true,
},
},
{
name: 'no-orphans',
comment:
"This is an orphan module - it's likely not used (anymore?). Either use it or " +
"remove it. If it's logical this module is an orphan (i.e. it's a config file), " +
'add an exception for it in your dependency-cruiser configuration. By default ' +
'this rule does not scrutinize dot-files (e.g. .eslintrc.js), TypeScript declaration ' +
'files (.d.ts), tsconfig.json and some of the babel and webpack configs.',
severity: 'warn',
from: {
orphan: true,
pathNot: [
'(^|/)\\.[^/]+\\.(js|cjs|mjs|ts|json)$', // dot files
'\\.d\\.ts$', // TypeScript declaration files
'(^|/)tsconfig\\.json$', // TypeScript config
'(^|/)(babel|webpack)\\.config\\.(js|cjs|mjs|ts|json)$', // other configs
],
},
to: {},
},
{
name: 'no-deprecated-core',
comment:
'A module depends on a node core module that has been deprecated. Find an alternative - these are ' +
"bound to exist - node doesn't deprecate lightly.",
severity: 'warn',
from: {},
to: {
dependencyTypes: ['core'],
path: [
'^(v8/tools/codemap)$',
'^(v8/tools/consarray)$',
'^(v8/tools/csvparser)$',
'^(v8/tools/logreader)$',
'^(v8/tools/profile_view)$',
'^(v8/tools/profile)$',
'^(v8/tools/SourceMap)$',
'^(v8/tools/splaytree)$',
'^(v8/tools/tickprocessor-driver)$',
'^(v8/tools/tickprocessor)$',
'^(node-inspect/lib/_inspect)$',
'^(node-inspect/lib/internal/inspect_client)$',
'^(node-inspect/lib/internal/inspect_repl)$',
'^(async_hooks)$',
'^(punycode)$',
'^(domain)$',
'^(constants)$',
'^(sys)$',
'^(_linklist)$',
'^(_stream_wrap)$',
],
},
},
{
name: 'not-to-deprecated',
comment:
'This module uses a (version of an) npm module that has been deprecated. Either upgrade to a later ' +
'version of that module, or find an alternative. Deprecated modules are a security risk.',
severity: 'warn',
from: {},
to: {
dependencyTypes: ['deprecated'],
},
},
{
name: 'no-non-package-json',
severity: 'error',
comment:
"This module depends on an npm package that isn't in the 'dependencies' section of your package.json. " +
"That's problematic as the package either (1) won't be available on live (2 - worse) will be " +
'available on live with an non-guaranteed version. Fix it by adding the package to the dependencies ' +
'in your package.json.',
from: {},
to: {
dependencyTypes: ['npm-no-pkg', 'npm-unknown'],
},
},
{
name: 'not-to-unresolvable',
comment:
"This module depends on a module that cannot be found ('resolved to disk'). If it's an npm " +
'module: add it to your package.json. In all other cases you likely already know what to do.',
severity: 'error',
from: {},
to: {
couldNotResolve: true,
},
},
{
name: 'no-duplicate-dep-types',
comment:
"Likely this module depends on an external ('npm') package that occurs more than once " +
'in your package.json i.e. bot as a devDependencies and in dependencies. This will cause ' +
'maintenance problems later on.',
severity: 'warn',
from: {},
to: {
moreThanOneDependencyType: true,
// as it's pretty common to have a type import be a type only import
// _and_ (e.g.) a devDependency - don't consider type-only dependency
// types for this rule
dependencyTypesNot: ['type-only'],
},
},
/* rules you might want to tweak for your specific situation: */
{
name: 'not-to-test',
comment:
"This module depends on code within a folder that should only contain tests. As tests don't " +
"implement functionality this is odd. Either you're writing a test outside the test folder " +
"or there's something in the test folder that isn't a test.",
severity: 'error',
from: {
pathNot: '^(test)',
},
to: {
path: '^(test)',
},
},
{
name: 'not-to-spec',
comment:
'This module depends on a spec (test) file. The sole responsibility of a spec file is to test code. ' +
"If there's something in a spec that's of use to other modules, it doesn't have that single " +
'responsibility anymore. Factor it out into (e.g.) a separate utility/ helper or a mock.',
severity: 'error',
from: {},
to: {
path: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$',
},
},
{
name: 'not-to-dev-dep',
severity: 'error',
comment:
"This module depends on an npm package from the 'devDependencies' section of your " +
'package.json. It looks like something that ships to production, though. To prevent problems ' +
"with npm packages that aren't there on production declare it (only!) in the 'dependencies'" +
'section of your package.json. If this module is development only - add it to the ' +
'from.pathNot re of the not-to-dev-dep rule in the dependency-cruiser configuration',
from: {
path: '^(src)',
pathNot: '\\.(spec|test)\\.(js|mjs|cjs|ts|ls|coffee|litcoffee|coffee\\.md)$',
},
to: {
dependencyTypes: ['npm-dev'],
},
},
{
name: 'optional-deps-used',
severity: 'info',
comment:
'This module depends on an npm package that is declared as an optional dependency ' +
"in your package.json. As this makes sense in limited situations only, it's flagged here. " +
"If you're using an optional dependency here by design - add an exception to your" +
'dependency-cruiser configuration.',
from: {},
to: {
dependencyTypes: ['npm-optional'],
},
},
{
name: 'peer-deps-used',
comment:
'This module depends on an npm package that is declared as a peer dependency ' +
'in your package.json. This makes sense if your package is e.g. a plugin, but in ' +
'other cases - maybe not so much. If the use of a peer dependency is intentional ' +
'add an exception to your dependency-cruiser configuration.',
severity: 'warn',
from: {},
to: {
dependencyTypes: ['npm-peer'],
},
},
],
options: {
/* conditions specifying which files not to follow further when encountered:
- path: a regular expression to match
- dependencyTypes: see https://github.com/sverweij/dependency-cruiser/blob/main/doc/rules-reference.md#dependencytypes-and-dependencytypesnot
for a complete list
*/
doNotFollow: {
path: 'node_modules',
},
/* conditions specifying which dependencies to exclude
- path: a regular expression to match
- dynamic: a boolean indicating whether to ignore dynamic (true) or static (false) dependencies.
leave out if you want to exclude neither (recommended!)
*/
// exclude : {
// path: '',
// dynamic: true
// },
/* pattern specifying which files to include (regular expression)
dependency-cruiser will skip everything not matching this pattern
*/
// includeOnly : '',
/* dependency-cruiser will include modules matching against the focus
regular expression in its output, as well as their neighbours (direct
dependencies and dependents)
*/
// focus : '',
/* list of module systems to cruise */
// moduleSystems: ['amd', 'cjs', 'es6', 'tsd'],
/* prefix for links in html and svg output (e.g. 'https://github.com/you/yourrepo/blob/develop/'
to open it on your online repo or `vscode://file/${process.cwd()}/` to
open it in visual studio code),
*/
// prefix: '',
/* false (the default): ignore dependencies that only exist before typescript-to-javascript compilation
true: also detect dependencies that only exist before typescript-to-javascript compilation
"specify": for each dependency identify whether it only exists before compilation or also after
*/
tsPreCompilationDeps: true,
/*
list of extensions to scan that aren't javascript or compile-to-javascript.
Empty by default. Only put extensions in here that you want to take into
account that are _not_ parsable.
*/
// extraExtensionsToScan: [".json", ".jpg", ".png", ".svg", ".webp"],
/* if true combines the package.jsons found from the module up to the base
folder the cruise is initiated from. Useful for how (some) mono-repos
manage dependencies & dependency definitions.
*/
// combinedDependencies: false,
/* if true leave symlinks untouched, otherwise use the realpath */
// preserveSymlinks: false,
/* TypeScript project file ('tsconfig.json') to use for
(1) compilation and
(2) resolution (e.g. with the paths property)
The (optional) fileName attribute specifies which file to take (relative to
dependency-cruiser's current working directory). When not provided
defaults to './tsconfig.json'.
*/
tsConfig: {
fileName: 'tsconfig.json',
},
/* Webpack configuration to use to get resolve options from.
The (optional) fileName attribute specifies which file to take (relative
to dependency-cruiser's current working directory. When not provided defaults
to './webpack.conf.js'.
The (optional) `env` and `arguments` attributes contain the parameters to be passed if
your webpack config is a function and takes them (see webpack documentation
for details)
*/
// webpackConfig: {
// fileName: './webpack.config.js',
// env: {},
// arguments: {},
// },
/* Babel config ('.babelrc', '.babelrc.json', '.babelrc.json5', ...) to use
for compilation (and whatever other naughty things babel plugins do to
source code). This feature is well tested and usable, but might change
behavior a bit over time (e.g. more precise results for used module
systems) without dependency-cruiser getting a major version bump.
*/
// babelConfig: {
// fileName: './.babelrc'
// },
/* List of strings you have in use in addition to cjs/ es6 requires
& imports to declare module dependencies. Use this e.g. if you've
re-declared require, use a require-wrapper or use window.require as
a hack.
*/
// exoticRequireStrings: [],
/* options to pass on to enhanced-resolve, the package dependency-cruiser
uses to resolve module references to disk. You can set most of these
options in a webpack.conf.js - this section is here for those
projects that don't have a separate webpack config file.
Note: settings in webpack.conf.js override the ones specified here.
*/
enhancedResolveOptions: {
/* List of strings to consider as 'exports' fields in package.json. Use
['exports'] when you use packages that use such a field and your environment
supports it (e.g. node ^12.19 || >=14.7 or recent versions of webpack).
If you have an `exportsFields` attribute in your webpack config, that one
will have precedence over the one specified here.
*/
exportsFields: ['exports'],
/* List of conditions to check for in the exports field. e.g. use ['imports']
if you're only interested in exposed es6 modules, ['require'] for commonjs,
or all conditions at once `(['import', 'require', 'node', 'default']`)
if anything goes for you. Only works when the 'exportsFields' array is
non-empty.
If you have a 'conditionNames' attribute in your webpack config, that one will
have precedence over the one specified here.
*/
conditionNames: ['import', 'require', 'node', 'default'],
/*
The extensions, by default are the same as the ones dependency-cruiser
can access (run `npx depcruise --info` to see which ones that are in
_your_ environment. If that list is larger than what you need (e.g.
it contains .js, .jsx, .ts, .tsx, .cts, .mts - but you don't use
TypeScript you can pass just the extensions you actually use (e.g.
[".js", ".jsx"]). This can speed up the most expensive step in
dependency cruising (module resolution) quite a bit.
*/
// extensions: [".js", ".jsx", ".ts", ".tsx", ".d.ts"],
/*
If your TypeScript project makes use of types specified in 'types'
fields in package.jsons of external dependencies, specify "types"
in addition to "main" in here, so enhanced-resolve (the resolver
dependency-cruiser uses) knows to also look there. You can also do
this if you're not sure, but still use TypeScript. In a future version
of dependency-cruiser this will likely become the default.
*/
mainFields: ['main', 'types'],
},
reporterOptions: {
dot: {
/* pattern of modules that can be consolidated in the detailed
graphical dependency graph. The default pattern in this configuration
collapses everything in node_modules to one folder deep so you see
the external modules, but not the innards your app depends upon.
*/
collapsePattern: 'node_modules/(@[^/]+/[^/]+|[^/]+)',
/* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions
for details and some examples. If you don't specify a theme
don't worry - dependency-cruiser will fall back to the default one.
*/
// theme: {
// graph: {
// /* use splines: "ortho" for straight lines. Be aware though
// graphviz might take a long time calculating ortho(gonal)
// routings.
// */
// splines: "true"
// },
// modules: [
// {
// criteria: { matchesFocus: true },
// attributes: {
// fillcolor: "lime",
// penwidth: 2,
// },
// },
// {
// criteria: { matchesFocus: false },
// attributes: {
// fillcolor: "lightgrey",
// },
// },
// {
// criteria: { matchesReaches: true },
// attributes: {
// fillcolor: "lime",
// penwidth: 2,
// },
// },
// {
// criteria: { matchesReaches: false },
// attributes: {
// fillcolor: "lightgrey",
// },
// },
// {
// criteria: { source: "^src/model" },
// attributes: { fillcolor: "#ccccff" }
// },
// {
// criteria: { source: "^src/view" },
// attributes: { fillcolor: "#ccffcc" }
// },
// ],
// dependencies: [
// {
// criteria: { "rules[0].severity": "error" },
// attributes: { fontcolor: "red", color: "red" }
// },
// {
// criteria: { "rules[0].severity": "warn" },
// attributes: { fontcolor: "orange", color: "orange" }
// },
// {
// criteria: { "rules[0].severity": "info" },
// attributes: { fontcolor: "blue", color: "blue" }
// },
// {
// criteria: { resolved: "^src/model" },
// attributes: { color: "#0000ff77" }
// },
// {
// criteria: { resolved: "^src/view" },
// attributes: { color: "#00770077" }
// }
// ]
// }
},
archi: {
/* pattern of modules that can be consolidated in the high level
graphical dependency graph. If you use the high level graphical
dependency graph reporter (`archi`) you probably want to tweak
this collapsePattern to your situation.
*/
collapsePattern:
'^(packages|src|lib|app|bin|test(s?)|spec(s?))/[^/]+|node_modules/(@[^/]+/[^/]+|[^/]+)',
/* Options to tweak the appearance of your graph.See
https://github.com/sverweij/dependency-cruiser/blob/main/doc/options-reference.md#reporteroptions
for details and some examples. If you don't specify a theme
for 'archi' dependency-cruiser will use the one specified in the
dot section (see above), if any, and otherwise use the default one.
*/
// theme: {
// },
},
text: {
highlightFocused: true,
},
},
},
};
// generated: dependency-cruiser@13.0.5 on 2023-07-08T03:48:00.632Z

1
.github/FUNDING.yml vendored Normal file
View File

@@ -0,0 +1 @@
open_collective: sern

View File

@@ -1,39 +0,0 @@
name: "CodeQL"
on:
push:
branches: [ main ]
paths: ["src/**/*"]
pull_request:
branches: [ main ]
paths: ["src/**/*"]
schedule:
- cron: '37 20 * * 4'
jobs:
analyze:
name: Analyze
runs-on: ubuntu-latest
permissions:
actions: read
contents: read
security-events: write
strategy:
fail-fast: false
matrix:
language: [ 'javascript' ]
steps:
- name: Checkout repository
uses: actions/checkout@v3
- name: Initialize CodeQL
uses: github/codeql-action/init@v2
with:
languages: ${{ matrix.language }}
- name: Autobuild
uses: github/codeql-action/autobuild@v2
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v2

View File

@@ -20,10 +20,10 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- name: Set up Node.js
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3
with:
node-version: 17
@@ -39,7 +39,7 @@ jobs:
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@v4
uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4
with:
commit-message: "style: pretty please"
branch: prettier

View File

@@ -15,6 +15,6 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: 'Checkout Repository'
uses: actions/checkout@v3
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- name: 'Dependency Review'
uses: actions/dependency-review-action@v2
uses: actions/dependency-review-action@0efb1d1d84fc9633afcdaad14c485cbbc90ef46c # v2

34
.github/workflows/npm-publish-dev.yml vendored Normal file
View File

@@ -0,0 +1,34 @@
name: Continuous Delivery
on:
push:
branches:
- main
paths:
- 'src/**'
- 'package.json'
jobs:
Publish:
name: Publishing Dev
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- name: Set up Node.js
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3
with:
node-version: 18
registry-url: 'https://registry.npmjs.org'
- name: Install Node.js dependencies
run: npm i && npm run build:dev
- name: Publish to npm
run: |
npm version premajor --preid "dev.$(git rev-parse --verify --short HEAD)" --git-tag-version=false
npm publish --tag dev
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

View File

@@ -2,17 +2,21 @@ name: NPM / Publish
on:
workflow_dispatch:
# We only publish if the version of sern handler is different. workflow automatically cancels if verson is the same
push:
branches:
- 'main'
jobs:
test-and-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3
with:
node-version: 17
- run: yarn --immutable
- run: yarn build:prod
- uses: JS-DevTools/npm-publish@v1
- uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1
with:
token: ${{ secrets.NPM_TOKEN }}
access: "public"

View File

@@ -6,7 +6,7 @@ jobs:
release-please:
runs-on: ubuntu-latest
steps:
- uses: google-github-actions/release-please-action@v3
- uses: google-github-actions/release-please-action@db8f2c60ee802b3748b512940dde88eabd7b7e01 # v3
with:
release-type: node
package-name: release-please-action

29
.github/workflows/test.yml vendored Normal file
View File

@@ -0,0 +1,29 @@
# This workflow will do a clean installation of node dependencies, cache/restore them, build the source code and run tests across different versions of node
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-nodejs
name: Node.js CI
on:
pull_request:
branches: [ "main" ]
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
node-version: [18.x, 19.x, 20.x]
# See supported Node.js release schedule at https://nodejs.org/en/about/releases/
steps:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- name: Use Node.js ${{ matrix.node-version }}
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm install -g yarn
- run: yarn install
- run: yarn test

6
.gitignore vendored
View File

@@ -91,3 +91,9 @@ dist
# Yarn files
.yarn/install-state.gz
.yarn/build-state.yml
.yalc
yalc.lock
*.svg

View File

@@ -113,3 +113,4 @@ tsconfig-cjs.json
tsconfig-esm.json
renovate.json
fortnite

View File

@@ -1,5 +1,103 @@
# Changelog
## [4.0.1](https://github.com/sern-handler/handler/compare/v4.0.0...v4.0.1) (2024-07-19)
### Bug Fixes
* add SDT typings to autocomplete commands ([#363](https://github.com/sern-handler/handler/issues/363)) ([92623d2](https://github.com/sern-handler/handler/commit/92623d2914fb80e31365f06cf896bb37f36fc814))
## [4.0.0](https://github.com/sern-handler/handler/compare/v3.3.4...v4.0.0) (2024-07-18)
### Features
* v4 ([#361](https://github.com/sern-handler/handler/issues/361)) ([9a8904f](https://github.com/sern-handler/handler/commit/9a8904f5aed4fa36b018ad73bbe58049bae33274))
### Miscellaneous Chores
* release 4.0.0 ([dda0e33](https://github.com/sern-handler/handler/commit/dda0e3395b6704862bfd3fda2a201e2cb9b45d2f))
## [3.3.4](https://github.com/sern-handler/handler/compare/v3.3.3...v3.3.4) (2024-03-18)
### Bug Fixes
* sern emitter err ([#358](https://github.com/sern-handler/handler/issues/358)) ([90e55df](https://github.com/sern-handler/handler/commit/90e55dfa1466c91e5da48922251309331921b1ef))
## [3.3.3](https://github.com/sern-handler/handler/compare/v3.3.2...v3.3.3) (2024-02-25)
### Bug Fixes
* rm deprecated class modules, clean up, rm indirection ([#355](https://github.com/sern-handler/handler/issues/355)) ([48f9f6e](https://github.com/sern-handler/handler/commit/48f9f6ec16e650d574bd24dcbb0ed176933bfe17))
* singleton init not being fired when inserting function ([07b11b3](https://github.com/sern-handler/handler/commit/07b11b357baac0c3c7055c022bc353995c80f766))
* typings and cleanup ([#356](https://github.com/sern-handler/handler/issues/356)) ([ce8c4bf](https://github.com/sern-handler/handler/commit/ce8c4bf6492b9680fb1c1a530d3e0028f214ad2f))
## [3.3.2](https://github.com/sern-handler/handler/compare/v3.3.1...v3.3.2) (2024-01-08)
### Bug Fixes
* presence feature not working on cjs applications ([#351](https://github.com/sern-handler/handler/issues/351)) ([4f23871](https://github.com/sern-handler/handler/commit/4f2387119acfde036d0d1626553e9050f55627d1))
## [3.3.1](https://github.com/sern-handler/handler/compare/v3.3.0...v3.3.1) (2024-01-07)
### Bug Fixes
* crashing when slash command is used as text command ([#349](https://github.com/sern-handler/handler/issues/349)) ([a359f73](https://github.com/sern-handler/handler/commit/a359f73fa24127a4964d411c8c1c0dfea5edc0f1))
### Reverts
* the last commit ([655bb8d](https://github.com/sern-handler/handler/commit/655bb8d35815fe0ce9797d8b169310a07b284ae0))
## [3.3.0](https://github.com/sern-handler/handler/compare/v3.2.1...v3.3.0) (2023-12-27)
### Features
* presence ([#345](https://github.com/sern-handler/handler/issues/345)) ([7458bef](https://github.com/sern-handler/handler/commit/7458befe8a5900480cd71900df02a8364837dc00))
## [3.2.1](https://github.com/sern-handler/handler/compare/v3.2.0...v3.2.1) (2023-12-21)
### Bug Fixes
* logger swap failing ([daac37c](https://github.com/sern-handler/handler/commit/daac37c28858c42b21042bdcb8141239db634e7d))
## [3.2.0](https://github.com/sern-handler/handler/compare/v3.1.1...v3.2.0) (2023-12-15)
### Miscellaneous Chores
* release 3.2.0 ([237c853](https://github.com/sern-handler/handler/commit/237c8537c66052309d7e13a7e6e0a4f7995c2558))
## [3.1.1](https://github.com/sern-handler/handler/compare/v3.1.0...v3.1.1) (2023-11-06)
### Bug Fixes
* queuing events ([fd39858](https://github.com/sern-handler/handler/commit/fd39858636d3038abb6d91021b65c99c488a3d6e))
* queuing events ([#332](https://github.com/sern-handler/handler/issues/332)) @Benzo-Fury ([#333](https://github.com/sern-handler/handler/issues/333)) ([fd39858](https://github.com/sern-handler/handler/commit/fd39858636d3038abb6d91021b65c99c488a3d6e))
## [3.1.0](https://github.com/sern-handler/handler/compare/v3.0.2...v3.1.0) (2023-09-04)
### Features
* add guaranteed `channelId` and `userId` getters to `Context` ([#320](https://github.com/sern-handler/handler/issues/320)) ([50253ca](https://github.com/sern-handler/handler/commit/50253ca322e7d6dbd2313139c0187a1028f71109))
* dispose hooks (deprecate useContainerRaw) ([#323](https://github.com/sern-handler/handler/issues/323)) ([26ccd11](https://github.com/sern-handler/handler/commit/26ccd118ff8cbcde94158a4d09fc0df18da9f254))
## [3.0.2](https://github.com/sern-handler/handler/compare/v3.0.1...v3.0.2) (2023-08-06)
### Bug Fixes
* invalid id for cts, mts, cjs, mjs files, node paths ([#318](https://github.com/sern-handler/handler/issues/318)) ([a7f5ea2](https://github.com/sern-handler/handler/commit/a7f5ea269fb344e221d10dbdc26a1611ffc8138f))
## [3.0.1](https://github.com/sern-handler/handler/compare/v3.0.0...v3.0.1) (2023-08-05)

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 sern
Copyright (c) 2024 sern
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -18,30 +18,17 @@
- For you. A framework that's tailored to your exact needs.
- Lightweight. Does a lot while being small.
- Latest features. Support for discord.js v14 and all of its interactions.
- Hybrid, customizable and composable commands. Create them just how you like.
- Start quickly. Plug and play or customize to your liking.
- Embraces reactive programming. For consistent and reliable backend.
- Switch and customize how errors are handled, logging, and more.
- 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.
## 📜 Installation
```sh
npm install @sern/handler
```
```sh
yarn add @sern/handler
```
```sh
pnpm add @sern/handler
```
[Start here!!](https://sern.dev/v4/reference/getting-started)
## 👶 Basic Usage
<details open><summary>ping.ts</summary>
<details><summary>ping.ts</summary>
```ts
export default commandModule({
@@ -55,65 +42,17 @@ export default commandModule({
});
```
</details>
<details open><summary>modal.ts</summary>
```ts
export default commandModule({
type: CommandType.Modal,
//Installed a plugin to make sure modal fields pass a validation.
plugins : [
assertFields({
fields: {
name: /^([^0-9]*)$/
},
failure: (errors, modal) => modal.reply('your submission did not pass the validations')
})
],
execute : (modal) => {
modal.reply('thanks for the submission!');
}
})
```
</details>
<details open><summary>index.ts</summary>
```ts
import { Client, GatewayIntentBits } from 'discord.js';
import { Sern, single, type Dependencies } from '@sern/handler';
//client has been declared previously
interface MyDependencies extends Dependencies {
'@sern/client': Singleton<Client>;
}
export const useContainer = Sern.makeDependencies<MyDependencies>({
build: root => root
.add({ '@sern/client': single(() => client) })
});
//View docs for all options
Sern.init({
defaultPrefix: '!', // removing defaultPrefix will shut down text commands
commands: 'src/commands',
// events: 'src/events' (optional),
containerConfig : {
get: useContainer
}
});
client.login("YOUR_BOT_TOKEN_HERE");
```
</details>
## 🤖 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 your favorite artists on Discord.
- [ava](https://github.com/SrIzan10/ava), A discord bot that plays KNGI and Gensokyo Radio.
- [Murayama](https://github.com/murayamabot/murayama), :pepega:
- [Protector (WIP)](https://github.com/needhamgary/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.

File diff suppressed because it is too large Load Diff

Before

Width:  |  Height:  |  Size: 119 KiB

1
fortnite Normal file
View File

@@ -0,0 +1 @@

View File

@@ -1,26 +1,23 @@
{
"name": "@sern/handler",
"packageManager": "yarn@3.5.0",
"version": "3.0.1",
"version": "4.0.1",
"description": "A complete, customizable, typesafe, & reactive framework for discord bots.",
"main": "./dist/index.js",
"module": "./dist/cjs/index.cjs",
"types": "./dist/index.d.ts",
"module": "./dist/index.js",
"exports": {
".": {
"import": "./dist/index.mjs",
"require": "./dist/index.js",
"types": "./dist/index.d.ts"
"import": "./dist/index.js",
"require": "./dist/index.js"
}
},
"scripts": {
"watch": "tsup --watch",
"clean-modules": "rimraf node_modules/ && npm install",
"watch": "tsc --watch",
"lint": "eslint src/**/*.ts",
"format": "eslint src/**/*.ts --fix",
"build:dev": "tsup --metafile",
"build:prod": "tsup --minify",
"prepare": "npm run build:prod",
"build:dev": "tsc",
"build:prod": "tsc",
"prepare": "tsc",
"pretty": "prettier --write .",
"tdd": "vitest",
"test": "vitest --run",
@@ -38,31 +35,23 @@
"author": "SernDevs",
"license": "MIT",
"dependencies": {
"iti": "^0.6.0",
"@sern/ioc": "^1.1.0",
"callsites": "^3.1.0",
"cron": "^3.1.7",
"deepmerge": "^4.3.1",
"rxjs": "^7.8.0",
"ts-results-es": "latest"
"ts-results-es": "^4.1.0"
},
"devDependencies": {
"@faker-js/faker": "^8.0.1",
"@types/node": "^18.15.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",
"dependency-cruiser": "^13.0.5",
"discord.js": "14.11.0",
"esbuild": "^0.17.0",
"discord.js": "^14.15.3",
"eslint": "8.39.0",
"prettier": "2.8.8",
"tsup": "^6.7.0",
"typescript": "5.0.2",
"vitest": "latest"
},
"prettier": {
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 4,
"arrowParens": "avoid"
"vitest": "^1.6.0"
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
@@ -96,5 +85,14 @@
"type": "git",
"url": "git+https://github.com/sern-handler/handler.git"
},
"homepage": "https://sern.dev"
"engines": {
"node": ">= 20.0.x"
},
"homepage": "https://sern.dev",
"overrides": {
"ws": "8.17.1"
},
"resolutions": {
"ws": "8.17.1"
}
}

View File

@@ -1,5 +1,9 @@
{
"extends": ["config:base", "helpers:pinGitHubActionDigests", "group:allNonMajor"],
"extends": [
"config:base",
"helpers:pinGitHubActionDigests",
"group:allNonMajor"
],
"major": {
"dependencyDashboardApproval": true,
"reviewers": ["EvolutionX-10", "jacoobes", "Murtatrxx"]

View File

@@ -1,9 +0,0 @@
export * as Id from './id';
export * from './operators';
export * from './predicates';
export * as Files from './module-loading';
export * from './functions';
export type { VoidResult } from '../types/core-plugin';
export { SernError } from './structures/enums';
export { ModuleStore } from './structures/module-store';
export * as DefaultServices from './structures/services';

View File

@@ -1,7 +0,0 @@
import type { AnyFunction } from '../../types/utility';
export interface Emitter {
addListener(eventName: string | symbol, listener: AnyFunction): this;
removeListener(eventName: string | symbol, listener: AnyFunction): this;
emit(eventName: string | symbol, ...payload: any[]): boolean;
}

View File

@@ -1,21 +0,0 @@
/**
* @since 2.0.0
*/
export interface ErrorHandling {
/**
* Number of times the process should throw an error until crashing and exiting
*/
keepAlive: number;
/**
* @deprecated
* Version 4 will remove this method
*/
crash(err: Error): never;
/**
* A function that is called on every crash. Updates keepAlive.
* If keepAlive is 0, the process crashes.
* @param error
*/
updateAlive(error: Error): void;
}

View File

@@ -1,6 +0,0 @@
export * from './error-handling';
export * from './logging';
export * from './module-manager';
export * from './module-store';
export * from './init';
export * from './emitter';

View File

@@ -1,9 +0,0 @@
import type { Awaitable } from '../../types/utility';
/**
* Represents an initialization contract.
* Let dependencies implement this to initiate some logic.
*/
export interface Init {
init(): Awaitable<unknown>;
}

View File

@@ -1,11 +0,0 @@
/**
* @since 2.0.0
*/
export interface Logging<T = unknown> {
error(payload: LogPayload<T>): void;
warning(payload: LogPayload<T>): void;
info(payload: LogPayload<T>): void;
debug(payload: LogPayload<T>): void;
}
export type LogPayload<T = unknown> = { message: T };

View File

@@ -1,22 +0,0 @@
import type {
CommandMeta,
CommandModule,
CommandModuleDefs,
Module,
} from '../../types/core-modules';
import { CommandType } from '../structures';
/**
* @since 2.0.0
*/
export interface ModuleManager {
get(id: string): string | undefined;
getMetadata(m: Module): CommandMeta | undefined;
setMetadata(m: Module, c: CommandMeta): void;
set(id: string, path: string): void;
getPublishableCommands(): Promise<CommandModule[]>;
getByNameCommandType<T extends CommandType>(
name: string,
commandType: T,
): Promise<CommandModuleDefs[T]> | undefined;
}

View File

@@ -1,9 +0,0 @@
import type { CommandMeta, Module } from '../../types/core-modules';
/**
* Represents a core module store that stores IDs mapped to file paths.
*/
export interface CoreModuleStore {
commands: Map<string, string>;
metadata: WeakMap<Module, CommandMeta>;
}

View File

@@ -1,62 +0,0 @@
import { CommandType, EventType, PluginType } from './structures';
import type { Plugin, PluginResult, EventArgs, CommandArgs } from '../types/core-plugin';
import type { ClientEvents } from 'discord.js';
export function makePlugin<V extends unknown[]>(
type: PluginType,
execute: (...args: any[]) => any,
): Plugin<V> {
return {
type,
execute,
} as Plugin<V>;
}
/**
* @since 2.5.0
* @__PURE__
*/
export function EventInitPlugin<I extends EventType>(
execute: (...args: EventArgs<I, PluginType.Init>) => PluginResult,
) {
return makePlugin(PluginType.Init, execute);
}
/**
* @since 2.5.0
* @__PURE__
*/
export function CommandInitPlugin<I extends CommandType>(
execute: (...args: CommandArgs<I, PluginType.Init>) => PluginResult,
) {
return makePlugin(PluginType.Init, execute);
}
/**
* @since 2.5.0
* @__PURE__
*/
export function CommandControlPlugin<I extends CommandType>(
execute: (...args: CommandArgs<I, PluginType.Control>) => PluginResult,
) {
return makePlugin(PluginType.Control, execute);
}
/**
* @since 2.5.0
* @__PURE__
*/
export function EventControlPlugin<I extends EventType>(
execute: (...args: EventArgs<I, PluginType.Control>) => PluginResult,
) {
return makePlugin(PluginType.Control, execute);
}
/**
* @since 2.5.0
* @Experimental
* A specialized function for creating control plugins with discord.js ClientEvents.
* Will probably be moved one day!
*/
export function DiscordEventControlPlugin<T extends keyof ClientEvents>(
name: T,
execute: (...args: ClientEvents[T]) => PluginResult,
) {
return makePlugin(PluginType.Control, execute);
}

View File

@@ -1,31 +1,45 @@
import { Err, Ok } from 'ts-results-es';
import { ApplicationCommandOptionType, AutocompleteInteraction } from 'discord.js';
import type { SernAutocompleteData, SernOptionsData } from '../types/core-modules';
import type { AnyCommandPlugin, AnyEventPlugin, Plugin } from '../types/core-plugin';
import { PluginType } from './structures';
import type { Module, SernAutocompleteData, SernOptionsData } from '../types/core-modules';
import type {
AnySelectMenuInteraction,
ButtonInteraction,
ChatInputCommandInteraction,
MessageContextMenuCommandInteraction,
ModalSubmitInteraction,
UserContextMenuCommandInteraction,
AutocompleteInteraction
} from 'discord.js';
import { ApplicationCommandOptionType, InteractionType } from 'discord.js';
import { PluginType } from './structures/enums';
import assert from 'assert';
import type { Payload } from '../types/utility';
//function wrappers for empty ok / err
export const ok = /* @__PURE__*/ () => Ok.EMPTY;
export const err = /* @__PURE__*/ () => Err.EMPTY;
/**
* Removes the first character(s) _[depending on prefix length]_ of the message
* @param msg
* @param prefix The prefix to remove
* @returns The message without the prefix
* @example
* message.content = '!ping';
* console.log(fmt(message.content, '!'));
* // [ 'ping' ]
*/
export function fmt(msg: string, prefix?: string): string[] {
if(!prefix) throw Error("Unable to parse message without prefix");
return msg.slice(prefix.length).trim().split(/\s+/g);
}
export function partitionPlugins(
arr: (AnyEventPlugin | AnyCommandPlugin)[] = [],
): [Plugin[], Plugin[]] {
export function partitionPlugins<T,V>
(arr: Array<{ type: PluginType }> = []): [T[], V[]] {
const controlPlugins = [];
const initPlugins = [];
for (const el of arr) {
switch (el.type) {
case PluginType.Control:
controlPlugins.push(el);
break;
case PluginType.Init:
initPlugins.push(el);
break;
case PluginType.Control: controlPlugins.push(el); break;
case PluginType.Init: initPlugins.push(el); break;
}
}
return [controlPlugins, initPlugins];
return [controlPlugins, initPlugins] as [T[], V[]];
}
/**
@@ -36,48 +50,77 @@ export function partitionPlugins(
export function treeSearch(
iAutocomplete: AutocompleteInteraction,
options: SernOptionsData[] | undefined,
): SernAutocompleteData | undefined {
): SernAutocompleteData & { parent?: string } | undefined {
if (options === undefined) return undefined;
//clone to prevent mutation of original command module
const _options = options.map(a => ({ ...a }));
let subcommands = new Set();
const subcommands = new Set();
while (_options.length > 0) {
const cur = _options.pop()!;
switch (cur.type) {
case ApplicationCommandOptionType.Subcommand:
{
case ApplicationCommandOptionType.Subcommand: {
subcommands.add(cur.name);
for (const option of cur.options ?? []) _options.push(option);
}
break;
case ApplicationCommandOptionType.SubcommandGroup:
{
} break;
case ApplicationCommandOptionType.SubcommandGroup: {
for (const command of cur.options ?? []) _options.push(command);
}
break;
default:
{
if ('autocomplete' in cur && cur.autocomplete) {
const choice = iAutocomplete.options.getFocused(true);
assert(
'command' in cur,
'No command property found for autocomplete option',
);
if (subcommands.size > 0) {
const parent = iAutocomplete.options.getSubcommand();
const parentAndOptionMatches =
subcommands.has(parent) && cur.name === choice.name;
if (parentAndOptionMatches) {
return cur;
}
} else {
if (cur.name === choice.name) {
return cur;
}
} break;
default: {
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;
} break;
}
}
}
interface InteractionTypable {
type: InteractionType;
}
//discord.js pls fix ur typings or i will >:(
type AnyMessageComponentInteraction = AnySelectMenuInteraction | ButtonInteraction;
type AnyCommandInteraction =
| ChatInputCommandInteraction
| MessageContextMenuCommandInteraction
| UserContextMenuCommandInteraction;
export function isMessageComponent(i: InteractionTypable): i is AnyMessageComponentInteraction {
return i.type === InteractionType.MessageComponent;
}
export function isCommand(i: InteractionTypable): i is AnyCommandInteraction {
return i.type === InteractionType.ApplicationCommand;
}
export function isAutocomplete(i: InteractionTypable): i is AutocompleteInteraction {
return i.type === InteractionType.ApplicationCommandAutocomplete;
}
export function isModal(i: InteractionTypable): i is ModalSubmitInteraction {
return i.type === InteractionType.ModalSubmit;
}
export function resultPayload<T extends 'success'|'warning'|'failure'>
(type: T, module?: Module, reason?: unknown) {
return { type, module, reason } as Payload & { type : T };
}
export function pipe<T>(arg: unknown, firstFn: Function, ...fns: Function[]): T {
let result = firstFn(arg);
for (let fn of fns) {
result = fn(result);
}
return result;
}

View File

@@ -1,23 +1,33 @@
import { ApplicationCommandType, ComponentType, Interaction, InteractionType } from 'discord.js';
import { CommandType, EventType } from './structures';
import { ApplicationCommandType, ComponentType, type Interaction, InteractionType } from 'discord.js';
import { CommandType, EventType } from './structures/enums';
const parseParams = (event: { customId: string }, append: string) => {
const hasSlash = event.customId.indexOf('/')
if(hasSlash === -1) {
return { id:event.customId+append };
}
const baseid = event.customId.substring(0, hasSlash);
const params = event.customId.substring(hasSlash+1);
return { id: baseid+append, params }
}
/**
* Construct unique ID for a given interaction object.
* @param event The interaction object for which to create an ID.
* @returns A unique string ID based on the type and properties of the interaction object.
* @returns An array of unique string IDs based on the type and properties of the interaction object.
*/
export function reconstruct<T extends Interaction>(event: T) {
switch (event.type) {
case InteractionType.MessageComponent: {
return `${event.customId}_C${event.componentType}`;
const data = parseParams(event, `_C${event.componentType}`)
return [data];
}
case InteractionType.ApplicationCommand:
case InteractionType.ApplicationCommandAutocomplete: {
return `${event.commandName}_A${event.commandType}`;
}
case InteractionType.ApplicationCommandAutocomplete:
return [{ id: `${event.commandName}_A${event.commandType}` }, { id: `${event.commandName}_B` }];
//Modal interactions are classified as components for sern
case InteractionType.ModalSubmit: {
return `${event.customId}_C1`;
const data = parseParams(event, '_M');
return [data];
}
}
}
@@ -25,39 +35,40 @@ export function reconstruct<T extends Interaction>(event: T) {
*
* A magic number to represent any commandtype that is an ApplicationCommand.
*/
const appBitField = 0b000000001111;
const PUBLISHABLE = 0b000000001111;
// Each index represents the exponent of a CommandType.
// Every CommandType is a power of two.
export const CommandTypeDiscordApi = [
1, // CommandType.Text
ApplicationCommandType.ChatInput,
ApplicationCommandType.User,
ApplicationCommandType.Message,
ComponentType.Button,
ComponentType.StringSelect,
1, // CommandType.Modal
ComponentType.UserSelect,
ComponentType.RoleSelect,
ComponentType.MentionableSelect,
ComponentType.ChannelSelect,
];
/*
* Generates a number based on CommandType.
* This corresponds to an ApplicationCommandType or ComponentType
* TextCommands are 0 as they aren't either or.
*/
function apiType(t: CommandType | EventType) {
if (t === CommandType.Both || t === CommandType.Modal) return 1;
return CommandTypeDiscordApi[Math.log2(t)];
}
const TypeMap = new Map<number, number>([[CommandType.Text, 0],
[CommandType.Both, 0],
[CommandType.Slash, ApplicationCommandType.ChatInput],
[CommandType.CtxUser, ApplicationCommandType.User],
[CommandType.CtxMsg, ApplicationCommandType.Message],
[CommandType.Button, ComponentType.Button],
[CommandType.StringSelect, ComponentType.StringSelect],
[CommandType.Modal, InteractionType.ModalSubmit],
[CommandType.UserSelect, ComponentType.UserSelect],
[CommandType.MentionableSelect, ComponentType.MentionableSelect],
[CommandType.RoleSelect, ComponentType.RoleSelect],
[CommandType.ChannelSelect, ComponentType.ChannelSelect]]);
/*
* Generates an id based on name and CommandType.
* A is for any ApplicationCommand. C is for any ComponentCommand
* Then, another number generated by apiType function is appended
* Then, another number fetched from TypeMap
*/
export function create(name: string, type: CommandType | EventType) {
const am = (appBitField & type) !== 0 ? 'A' : 'C';
return name + '_' + am + apiType(type);
if(type == CommandType.Text) {
return `${name}_T`;
}
if(type == CommandType.Both) {
return `${name}_B`;
}
if(type == CommandType.Modal) {
return `${name}_M`;
}
const am = (PUBLISHABLE & type) !== 0 ? 'A' : 'C';
return `${name}_${am}${TypeMap.get(type)!}`
}

View File

@@ -1,4 +0,0 @@
export * from './contracts';
export * from './create-plugins';
export * from './structures';
export * from './ioc';

55
src/core/interfaces.ts Normal file
View File

@@ -0,0 +1,55 @@
import type { AnyFunction } from '../types/utility';
/**
* Represents an initialization contract.
* Let dependencies implement this to initiate some logic.
*/
export interface Init {
init(): unknown;
}
/**
* Represents a Disposable contract.
* Let dependencies implement this to dispose and cleanup.
*/
export interface Disposable {
dispose(): unknown;
}
export interface Emitter {
addListener(eventName: string | symbol, listener: AnyFunction): this;
removeListener(eventName: string | symbol, listener: AnyFunction): this;
emit(eventName: string | symbol, ...payload: any[]): boolean;
}
/**
* @since 2.0.0
*/
export interface ErrorHandling {
/**
* @deprecated
* Version 4 will remove this method
*/
crash(err: Error): never;
/**
* A function that is called on every throw.
* @param error
*/
updateAlive(error: Error): void;
}
/**
* @since 2.0.0
*/
export interface Logging<T = unknown> {
error(payload: LogPayload<T>): void;
warning(payload: LogPayload<T>): void;
info(payload: LogPayload<T>): void;
debug(payload: LogPayload<T>): void;
}
export type LogPayload<T = unknown> = { message: T };

163
src/core/ioc.ts Normal file
View File

@@ -0,0 +1,163 @@
import { Service as $Service, Services as $Services } from '@sern/ioc/global'
import { Container } from '@sern/ioc';
import * as Contracts from './interfaces';
import * as __Services from './structures/default-services';
import type { Logging } from './interfaces';
import { __init_container, useContainerRaw } from '@sern/ioc/global';
import { EventEmitter } from 'node:events';
import { Client } from 'discord.js';
import { Module } from '../types/core-modules';
import { UnpackFunction } from '../types/utility';
export function disposeAll(logger: Logging|undefined) {
useContainerRaw()
?.disposeAll()
.then(() => logger?.info({ message: 'Cleaning container and crashing' }));
}
type Insertable = | ((container: Dependencies) => object)
| object
const dependencyBuilder = (container: Container) => {
return {
/**
* Insert a dependency into your container.
* Supply the correct key and dependency
*/
add(key: keyof Dependencies, v: Insertable) {
if(typeof v !== 'function') {
container.addSingleton(key, v)
} else {
//@ts-ignore
container.addWiredSingleton(key, (cntr) => v(cntr))
}
},
/**
* @param key the key of the dependency
* @param v The dependency to swap out.
* Swap out a preexisting dependency.
*/
swap(key: keyof Dependencies, v: Insertable) {
if(typeof v !== 'function') {
container.swap(key, v);
} else {
container.swap(key, v(container.deps()));
}
},
};
};
type ValidDependencyConfig =
(c: ReturnType<typeof dependencyBuilder>) => any
/**
* makeDependencies constructs a dependency injection container for sern handler to use.
* This is required to start the handler, and is to be called before Sern.init.
* @example
* ```ts
* await makeDependencies(({ add }) => {
* add('@sern/client', new Client({ intents, partials })
* })
* ```
*/
export async function makeDependencies (conf: ValidDependencyConfig) {
const container = await __init_container({ autowire: false });
//We only include logger if it does not exist
const includeLogger = !container.hasKey('@sern/logger');
if(includeLogger) {
container.addSingleton('@sern/logger', new __Services.DefaultLogging);
}
container.addSingleton('@sern/errors', new __Services.DefaultErrorHandling);
container.addSingleton('@sern/modules', new Map);
container.addSingleton('@sern/emitter', new EventEmitter)
container.addSingleton('@sern/scheduler', new __Services.TaskScheduler)
conf(dependencyBuilder(container));
await container.ready();
}
/**
* The Service api, which allows users to access dependencies in places IOC cannot reach.
* To obtain intellisense, ensure a .d.ts file exists in the root of compilation.
* Our scaffolding tool takes care of this.
* Note: this method only works AFTER your container has been initiated
* @since 3.0.0
* @example
* ```ts
* const client = Service('@sern/client');
* ```
* @param key a key that corresponds to a dependency registered.
* @throws if container is absent or not present
*/
export function Service<const T extends keyof Dependencies>(key: T) {
return $Service(key) as Dependencies[T]
}
/**
* @since 3.0.0
* The plural version of {@link Service}
* @throws if container is absent or not present
* @returns array of dependencies, in the same order of keys provided
*
*/
export function Services<const T extends (keyof Dependencies)[]>(...keys: [...T]) {
return $Services<T, IntoDependencies<T>>(...keys)
}
/**
* @deprecated
* Creates a singleton object.
* @param cb
*/
export function single<T>(cb: () => T) {
console.log('The `single` function is deprecated and has no effect')
return cb();
}
/**
* @deprecated
* @since 2.0.0
* Creates a transient object
* @param cb
*/
export function transient<T>(cb: () => () => T) {
console.log('The `transient` function is deprecated and has no effect')
return cb()();
}
export type DependencyFromKey<T extends keyof Dependencies> = Dependencies[T];
export type IntoDependencies<Tuple extends [...any[]]> = {
[Index in keyof Tuple]: UnpackFunction<NonNullable<DependencyFromKey<Tuple[Index]>>>; //Unpack and make NonNullable
} & { length: Tuple['length'] };
export interface CoreDependencies {
/**
* discord.js client.
*/
'@sern/client': Client;
/**
* sern emitter listens to events that happen throughout
* the handler. some include module.register, module.activate.
*/
'@sern/emitter': Contracts.Emitter;
/**
* An error handler which is the final step before
* the sern process actually crashes.
*/
'@sern/errors': Contracts.ErrorHandling;
/**
* Optional logger. Performs ... logging
*/
'@sern/logger'?: Contracts.Logging;
/**
* Readonly module store. sern stores these
* by module.meta.id -> Module
*/
'@sern/modules': Map<string, Module>;
'@sern/scheduler': __Services.TaskScheduler
}

View File

@@ -1,34 +0,0 @@
import * as assert from 'assert';
import { composeRoot, useContainer } from './dependency-injection';
import type { DependencyConfiguration } from '../../types/ioc';
import { CoreContainer } from './container';
//SIDE EFFECT: GLOBAL DI
let containerSubject: CoreContainer<Partial<Dependencies>>;
/**
* Returns the underlying data structure holding all dependencies.
* Exposes methods from iti
*/
export function useContainerRaw() {
assert.ok(
containerSubject && containerSubject.isReady(),
"Could not find container or container wasn't ready. Did you call makeDependencies?",
);
return containerSubject;
}
/**
* @since 2.0.0
* @param conf a configuration for creating your project dependencies
*/
export async function makeDependencies<const T extends Dependencies>(
conf: DependencyConfiguration,
) {
//Until there are more optional dependencies, just check if the logger exists
//SIDE EFFECT
containerSubject = new CoreContainer();
await composeRoot(containerSubject, conf);
return useContainer<T>();
}

View File

@@ -1,67 +0,0 @@
import { Container } from 'iti';
import { SernEmitter } from '../';
import { isAsyncFunction } from 'node:util/types';
import * as assert from 'node:assert';
import { Subject } from 'rxjs';
import { DefaultServices, ModuleStore } from '../_internal';
/**
* Provides all the defaults for sern to function properly.
* The only user provided dependency needs to be @sern/client
*/
export class CoreContainer<T extends Partial<Dependencies>> extends Container<T, {}> {
private ready$ = new Subject<never>();
private beenCalled = new Set<PropertyKey>();
constructor() {
super();
this.listenForInsertions();
(this as Container<{}, {}>)
.add({
'@sern/errors': () => new DefaultServices.DefaultErrorHandling(),
'@sern/emitter': () => new SernEmitter(),
'@sern/store': () => new ModuleStore(),
})
.add(ctx => {
return {
'@sern/modules': () =>
new DefaultServices.DefaultModuleManager(ctx['@sern/store']),
};
});
}
private listenForInsertions() {
assert.ok(
!this.isReady(),
'listening for init functions should only occur prior to sern being ready.',
);
const unsubscriber = this.on('containerUpserted', e => this.callInitHooks(e));
this.ready$.subscribe({
complete: unsubscriber,
});
}
private async callInitHooks(e: { key: keyof T; newContainer: T[keyof T] | null }) {
const dep = e.newContainer;
assert.ok(dep);
//Ignore any dependencies that are not objects or array
if (typeof dep !== 'object' || Array.isArray(dep)) {
return;
}
if ('init' in dep && typeof dep.init === 'function' && !this.beenCalled.has(e.key)) {
isAsyncFunction(dep.init) ? await dep.init() : dep.init();
this.beenCalled.add(e.key);
}
}
isReady() {
return this.ready$.closed;
}
ready() {
this.ready$.unsubscribe();
}
}

View File

@@ -1,85 +0,0 @@
import type { CoreDependencies, DependencyConfiguration, IntoDependencies } from '../../types/ioc';
import { SernError, DefaultServices } from '../_internal';
import { useContainerRaw } from './base';
import { CoreContainer } from './container';
/**
* @__PURE__
* @since 2.0.0.
* Creates a singleton object.
* @param cb
*/
export function single<T>(cb: () => T) {
return cb;
}
/**
* @__PURE__
* @since 2.0.0
* Creates a transient object
* @param cb
*/
export function transient<T>(cb: () => () => T) {
return cb;
}
/**
* The new Service api, a cleaner alternative to useContainer
* To obtain intellisense, ensure a .d.ts file exists in the root of compilation.
* Usually our scaffolding tool takes care of this.
* @since 3.0.0
* @example
* ```ts
* const client = Service('@sern/client');
* ```
* @param key a key that corresponds to a dependency registered.
*
*/
export function Service<const T extends keyof Dependencies>(key: T) {
return useContainerRaw().get(key)!;
}
/**
* @since 3.0.0
* The plural version of {@link Service}
* @returns array of dependencies, in the same order of keys provided
*/
export function Services<const T extends (keyof Dependencies)[]>(...keys: [...T]) {
const container = useContainerRaw();
return keys.map(k => container.get(k)!) as IntoDependencies<T>;
}
/**
* 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 async function composeRoot(
container: CoreContainer<Partial<Dependencies>>,
conf: DependencyConfiguration,
) {
//container should have no client or logger yet.
const hasLogger = conf.exclude?.has('@sern/logger');
if (!hasLogger) {
container.upsert({
'@sern/logger': () => new DefaultServices.DefaultLogging(),
});
}
//Build the container based on the callback provided by the user
conf.build(container as CoreContainer<Omit<CoreDependencies, '@sern/client'>>);
try {
container.get('@sern/client');
} catch {
throw new Error(SernError.MissingRequired + ' No client was provided');
}
if (!hasLogger) {
container.get('@sern/logger')?.info({ message: 'All dependencies loaded successfully.' });
}
container.ready();
}
export function useContainer<const T extends Dependencies>() {
return <V extends (keyof T)[]>(...keys: [...V]) =>
keys.map(key => useContainerRaw().get(key as keyof Dependencies)) as IntoDependencies<V>;
}

View File

@@ -1,2 +0,0 @@
export { useContainerRaw, makeDependencies } from './base';
export { Service, Services, single, transient } from './dependency-injection';

View File

@@ -1,13 +1,25 @@
import { Result } from 'ts-results-es';
import { type Observable, from, mergeMap, ObservableInput } from 'rxjs';
import { readdir, stat } from 'fs/promises';
import { basename, extname, join, resolve } from 'path';
import assert from 'assert';
import { createRequire } from 'node:module';
import type { ImportPayload, Wrapper } from '../types/core';
import type { Module } from '../types/core-modules';
import path from 'node:path';
import { type Dirent, existsSync } from 'node:fs';
import { readdir } from 'fs/promises';
import * as Id from './id'
import { Module } from '../types/core-modules';
export const parseCallsite = (site: string) => {
const pathobj = path.posix.parse(site.replace(/file:\\?/, "")
.split(path.sep)
.join(path.posix.sep))
return { name: pathobj.name,
absPath : path.posix.format(pathobj) }
}
export const shouldHandle = (pth: string, filenam: string) => {
const file_name = filenam+path.extname(pth);
let newPath = path.join(path.dirname(pth), file_name)
.replace(/file:\\?/, "");
return { exists: existsSync(newPath),
path: 'file://'+newPath };
}
export type ModuleResult<T> = Promise<ImportPayload<T>>;
/**
* Import any module based on the absolute path.
@@ -15,7 +27,6 @@ export type ModuleResult<T> = Promise<ImportPayload<T>>;
* commonjs, javascript :
* ```js
* exports = commandModule({ })
*
* //or
* exports.default = commandModule({ })
* ```
@@ -23,119 +34,39 @@ export type ModuleResult<T> = Promise<ImportPayload<T>>;
* export default commandModule({})
*/
export async function importModule<T>(absPath: string) {
let module = await import(absPath).then(esm => esm.default);
let fileModule = await import(absPath);
assert(
module,
'Found no default export for command module at ' +
absPath +
'Forgot to ignore with "!"? (!filename.ts)?',
);
if ('default' in module) {
module = module.default;
let commandModule: Module = fileModule.default;
if(!commandModule) {
throw Error(`No default export @ ${absPath}`);
}
return Result.wrap(() => module.getInstance()).unwrapOr(module) as T;
}
export async function defaultModuleLoader<T extends Module>(absPath: string): ModuleResult<T> {
let module = await importModule<T>(absPath);
assert.ok(
module,
"Found an undefined module. Forgot to ignore it with a '!' ie (!filename.ts)?",
);
return { module, absPath };
}
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
*/
export function buildModuleStream<T extends Module>(
input: ObservableInput<string>,
): Observable<ImportPayload<T>> {
return from(input).pipe(mergeMap(defaultModuleLoader<T>));
}
export const getFullPathTree = (dir: string, mode: boolean) => readPaths(resolve(dir), mode);
export const filename = (path: string) => fmtFileName(basename(path));
const isSkippable = (filename: string) => {
//empty string is for non extension files (directories)
const validExtensions = ['.js', '.cjs', '.mts', '.mjs', '.cts', '.ts', ''];
return filename[0] === '!' || !validExtensions.includes(extname(filename));
};
async function deriveFileInfo(dir: string, file: string) {
const fullPath = join(dir, file);
return {
fullPath,
fileStats: await stat(fullPath),
base: basename(file),
if ('default' in commandModule) {
commandModule = commandModule.default as Module;
}
const p = path.parse(absPath)
commandModule.name ??= p.name; commandModule.description ??= "...";
commandModule.meta = {
id: Id.create(commandModule.name, commandModule.type),
absPath,
};
return { module: commandModule as T };
}
async function* readPaths(dir: string, shouldDebug: boolean): AsyncGenerator<string> {
try {
const files = await readdir(dir);
for (const file of files) {
const { fullPath, fileStats, base } = await deriveFileInfo(dir, file);
if (fileStats.isDirectory()) {
//Todo: refactor so that i dont repeat myself for files (line 71)
if (isSkippable(base)) {
if (shouldDebug) console.info(`ignored directory: ${fullPath}`);
} else {
yield* readPaths(fullPath, shouldDebug);
}
} else {
if (isSkippable(base)) {
if (shouldDebug) console.info(`ignored: ${fullPath}`);
} else {
yield 'file:///' + fullPath;
}
export async function* readRecursive(dir: string, directoryPlugins: string[] = []): AsyncGenerator<[string, string[]]> {
const files = await readdir(dir, { withFileTypes: true });
const pluginFile = files.find(file => file.isFile() && file.name.startsWith('!plugins')) as Dirent;
for (const file of files) {
const fullPath = path.posix.join(dir, file.name);
const plugins = pluginFile ? [...directoryPlugins, path.posix.join(dir, pluginFile.name) ] : directoryPlugins
if (file.isDirectory()) {
if (!file.name.startsWith('!')) {
yield* readRecursive(fullPath, plugins);
}
} else if (!file.name.startsWith('!')) {
yield ["file:///"+path.resolve(fullPath), plugins];
}
} catch (err) {
throw err;
}
}
const requir = createRequire(import.meta.url);
export function loadConfig(wrapper: Wrapper | 'file'): Wrapper {
if (wrapper === 'file') {
console.log('Experimental loading of sern.config.json');
const config = requir(resolve('sern.config.json')) as {
language: string;
defaultPrefix?: string;
mode?: 'PROD' | 'DEV';
paths: {
base: string;
commands: string;
events?: string;
};
};
const makePath = (dir: keyof typeof config.paths) =>
config.language === 'typescript'
? join('dist', config.paths[dir]!)
: join(config.paths[dir]!);
console.log('Loading config: ', config);
const commandsPath = makePath('commands');
console.log('Commands path is set to', commandsPath);
let eventsPath: string | undefined;
if (config.paths.events) {
eventsPath = makePath('events');
console.log('Events path is set to', eventsPath);
}
return {
defaultPrefix: config.defaultPrefix,
commands: commandsPath,
events: eventsPath,
mode: config.mode,
};
}
return wrapper;
}

View File

@@ -1,112 +1,51 @@
import { ClientEvents } from 'discord.js';
import { CommandType, EventType, PluginType } from '../core/structures';
import type { ClientEvents } from 'discord.js';
import { EventType } from '../core/structures/enums';
import type {
AnyCommandPlugin,
AnyEventPlugin,
CommandArgs,
ControlPlugin,
EventArgs,
InitPlugin,
} from '../types/core-plugin';
import type {
CommandModule,
EventModule,
InputCommand,
InputEvent,
Module,
ScheduledTask,
} from '../types/core-modules';
import { 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): CommandModule {
export function commandModule(mod: InputCommand): Module {
const [onEvent, plugins] = partitionPlugins(mod.plugins);
return {
...mod,
onEvent,
plugins,
} as CommandModule;
return { ...mod,
onEvent,
plugins,
locals: {} } as Module;
}
/**
* @since 1.0.0
* The wrapper function to define event modules for sern
* @param mod
*/
export function eventModule(mod: InputEvent): EventModule {
export function eventModule(mod: InputEvent): Module {
const [onEvent, plugins] = partitionPlugins(mod.plugins);
return {
...mod,
plugins,
onEvent,
} as EventModule;
if(onEvent.length !== 0) throw Error("Event modules cannot have ControlPlugins");
return { ...mod,
plugins,
locals: {} } as Module;
}
/** Create event modules from discord.js client events,
* This is an {@link eventModule} for discord events,
* where typings can be very bad.
* @Experimental
* @param mod
*/
export function discordEvent<T extends keyof ClientEvents>(mod: {
name: T;
plugins?: AnyEventPlugin[];
once?: boolean;
execute: (...args: ClientEvents[T]) => Awaitable<unknown>;
}) {
return eventModule({
type: EventType.Discord,
...mod,
});
return eventModule({ type: EventType.Discord, ...mod, });
}
function prepareClassPlugins(c: Module) {
const [onEvent, initPlugins] = partitionPlugins(c.plugins);
c.plugins = initPlugins as InitPlugin[];
c.onEvent = onEvent as ControlPlugin[];
}
//
// Class modules:
// Can be refactored.
// Both implement singleton, could I make them inherit a singleton parent class?
/**
* @Experimental
* Will be refactored / changed in future
*/
export abstract class CommandExecutable<const Type extends CommandType = CommandType> {
abstract type: Type;
plugins: AnyCommandPlugin[] = [];
private static _instance: CommandModule;
export function scheduledTask(ism: ScheduledTask) { return ism }
static getInstance() {
if (!CommandExecutable._instance) {
//@ts-ignore
CommandExecutable._instance = new this();
prepareClassPlugins(CommandExecutable._instance);
}
return CommandExecutable._instance;
}
abstract execute(...args: CommandArgs<Type, PluginType.Control>): Awaitable<unknown>;
}
/**
* @Experimental
* Will be refactored in future
*/
export abstract class EventExecutable<Type extends EventType> {
abstract type: Type;
plugins: AnyEventPlugin[] = [];
private static _instance: EventModule;
static getInstance() {
if (!EventExecutable._instance) {
//@ts-ignore
EventExecutable._instance = new this();
prepareClassPlugins(EventExecutable._instance);
}
return EventExecutable._instance;
}
abstract execute(...args: EventArgs<Type, PluginType.Control>): Awaitable<unknown>;
}

View File

@@ -1,86 +0,0 @@
/**
* 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,
fromEvent,
map,
Observable,
of,
OperatorFunction,
pipe,
share,
} from 'rxjs';
import { Emitter, ErrorHandling, Logging } from './contracts';
import util from 'node:util';
import type { PluginResult, VoidResult } from '../types/core-plugin';
import type { Result } from 'ts-results-es'
/**
* if {src} is true, mapTo V, else ignore
* @param item
*/
export function filterMapTo<V>(item: () => V): OperatorFunction<boolean, V> {
return 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 concatMap(async plugin => {
if (Array.isArray(args)) {
return plugin.execute(...args);
}
return plugin.execute(args);
});
}
export const arrayifySource = map(src => (Array.isArray(src) ? (src as unknown[]) : [src]));
/**
* Checks if the stream of results is all ok.
*/
export const everyPluginOk: OperatorFunction<VoidResult, boolean> = pipe(
every(result => result.ok),
defaultIfEmpty(true),
);
export const sharedEventStream = <T>(e: Emitter, eventName: string) => {
return (fromEvent(e, eventName) as Observable<T>).pipe(share());
};
export function handleError<C>(crashHandler: ErrorHandling, logging?: Logging) {
return (pload: unknown, caught: Observable<C>) => {
// This is done to fit the ErrorHandling contract
const err = pload instanceof Error ? pload : Error(util.inspect(pload, { colors: true }));
//formatted payload
logging?.error({ message: util.inspect(pload) });
crashHandler.updateAlive(err);
return caught;
};
}
// Temporary until i get rxjs operators working on ts-results-es
export const filterTap = <K, R>(onErr: (e: R) => void): OperatorFunction<Result<K, R>, K> =>
pipe(
concatMap(result => {
if(result.ok) {
return of(result.val)
}
onErr(result.val);
return EMPTY
})
)

45
src/core/plugin.ts Normal file
View File

@@ -0,0 +1,45 @@
import { CommandType, PluginType } from './structures/enums';
import type { Plugin, PluginResult, CommandArgs, InitArgs } from '../types/core-plugin';
import { Err, Ok } from 'ts-results-es';
export function makePlugin<V extends unknown[]>(
type: PluginType,
execute: (...args: any[]) => any,
): Plugin<V> {
return { type, execute } as Plugin<V>;
}
/**
* @since 2.5.0
*/
export function EventInitPlugin(execute: (args: InitArgs) => PluginResult) {
return makePlugin(PluginType.Init, execute);
}
/**
* @since 2.5.0
*/
export function CommandInitPlugin<I extends CommandType>(
execute: (args: InitArgs) => PluginResult
) {
return makePlugin(PluginType.Init, execute);
}
/**
* @since 2.5.0
*/
export function CommandControlPlugin<I extends CommandType>(
execute: (...args: CommandArgs<I>) => PluginResult,
) {
return makePlugin(PluginType.Control, execute);
}
/**
* @since 1.0.0
* The object passed into every plugin to control a command's behavior
*/
export const controller = {
next: (val?: Record<string,unknown>) => Ok(val),
stop: (val?: string) => Err(val),
};
export type Controller = typeof controller;

View File

@@ -1,34 +0,0 @@
import type {
AnySelectMenuInteraction,
AutocompleteInteraction,
ButtonInteraction,
ChatInputCommandInteraction,
MessageContextMenuCommandInteraction,
ModalSubmitInteraction,
UserContextMenuCommandInteraction,
} from 'discord.js';
import { InteractionType } from 'discord.js';
interface InteractionTypable {
type: InteractionType;
}
//discord.js pls fix ur typings or i will >:(
type AnyMessageComponentInteraction = AnySelectMenuInteraction | ButtonInteraction;
type AnyCommandInteraction =
| ChatInputCommandInteraction
| MessageContextMenuCommandInteraction
| UserContextMenuCommandInteraction;
export function isMessageComponent(i: InteractionTypable): i is AnyMessageComponentInteraction {
return i.type === InteractionType.MessageComponent;
}
export function isCommand(i: InteractionTypable): i is AnyCommandInteraction {
return i.type === InteractionType.ApplicationCommand;
}
export function isAutocomplete(i: InteractionTypable): i is AutocompleteInteraction {
return i.type === InteractionType.ApplicationCommandAutocomplete;
}
export function isModal(i: InteractionTypable): i is ModalSubmitInteraction {
return i.type === InteractionType.ModalSubmit;
}

66
src/core/presences.ts Normal file
View File

@@ -0,0 +1,66 @@
import type { ActivitiesOptions } from "discord.js";
import type { IntoDependencies } from "./ioc";
import type { Emitter } from "./interfaces";
type Status = 'online' | 'idle' | 'invisible' | 'dnd'
type PresenceReduce = (previous: Presence.Result) => Presence.Result;
export const Presence = {
/**
* A small wrapper to provide type inference.
* Create a Presence module which **MUST** be put in a file called presence.(language-extension)
* adjacent to the file where **Sern.init** is CALLED.
*/
module : <T extends (keyof Dependencies)[]>(conf: Presence.Config<T>) => conf,
/**
* Create a Presence body which can be either:
* - once, the presence is activated only once.
* - repeated, per cycle or event, the presence can be changed.
*/
of : (root: Omit<Presence.Result, 'repeat' | 'onRepeat'>) => {
return {
/**
* @example
* Presence
* .of({ activities: [{ name: "deez nuts" }] }) //starts presence with "deez nuts".
* .repeated(prev => {
* return {
* afk: true,
* activities: prev.activities?.map(s => ({ ...s, name: s.name+"s" }))
* };
* }, 10000)) //every 10 s, the callback sets the presence to the value returned.
*/
repeated: (onRepeat: PresenceReduce, repeat: number | [Emitter, string]) => {
return { repeat, onRepeat, ...root }
},
/**
* @example
* ```ts
* Presence.of({
* activities: [{ name: "Chilling out" }]
* }).once() // Sets the presence once, with what's provided in '.of()'
* ```
*/
once: () => root
};
}
}
export declare namespace Presence {
export type Config<T extends (keyof Dependencies)[]> = {
inject?: [...T]
execute: (...v: IntoDependencies<T>) => Presence.Result;
}
export interface Result {
status?: Status;
afk?: boolean;
activities?: ActivitiesOptions[];
shardId?: number[];
repeat?: number | [Emitter, string];
onRepeat?: (previous: Result) => Result;
}
}

View File

@@ -1,4 +1,4 @@
import {
import type {
BaseInteraction,
ChatInputCommandInteraction,
Client,
@@ -11,8 +11,10 @@ import {
import { CoreContext } from '../structures/core-context';
import { Result, Ok, Err } from 'ts-results-es';
import * as assert from 'assert';
import type { ReplyOptions } from '../../types/utility';
import { fmt } from '../functions'
import { SernError } from './enums';
type ReplyOptions = string | Omit<InteractionReplyOptions, 'fetchReply'> | MessageReplyOptions;
/**
* @since 1.0.0
@@ -20,76 +22,122 @@ type ReplyOptions = string | Omit<InteractionReplyOptions, 'fetchReply'> | Messa
* Message and ChatInputCommandInteraction
*/
export class Context extends CoreContext<Message, ChatInputCommandInteraction> {
/*
* @Experimental
*/
get options() {
return this.interaction.options;
if(this.isMessage()) {
const [, ...rest] = fmt(this.message.content, this.prefix);
return rest;
} else {
return this.interaction.options;
}
}
protected constructor(protected ctx: Result<Message, ChatInputCommandInteraction>) {
protected constructor(protected ctx: Result<Message, ChatInputCommandInteraction>,
private __prefix?: string) {
super(ctx);
}
public get prefix() {
return this.__prefix;
}
public get id(): Snowflake {
return this.ctx.val.id;
return safeUnwrap(this.ctx
.map(m => m.id)
.mapErr(i => i.id));
}
public get channel() {
return this.ctx.val.channel;
return safeUnwrap(this.ctx.map(m => m.channel).mapErr(i => i.channel));
}
public get channelId(): Snowflake {
return safeUnwrap(this.ctx
.map(m => m.channelId)
.mapErr(i => i.channelId));
}
/**
* If context is holding a message, message.author
* else, interaction.user
*/
public get user(): User {
return safeUnwrap(this.ctx.map(m => m.author).mapErr(i => i.user));
return safeUnwrap(this.ctx
.map(m => m.author)
.mapErr(i => i.user));
}
public get userId(): Snowflake {
return this.user.id;
}
public get createdTimestamp(): number {
return this.ctx.val.createdTimestamp;
return safeUnwrap(this.ctx
.map(m => m.createdTimestamp)
.mapErr(i => i.createdTimestamp));
}
public get guild() {
return this.ctx.val.guild;
return safeUnwrap(this.ctx
.map(m => m.guild)
.mapErr(i => i.guild));
}
public get guildId() {
return this.ctx.val.guildId;
return safeUnwrap(this.ctx
.map(m => m.guildId)
.mapErr(i => i.guildId));
}
/*
* interactions can return APIGuildMember if the guild it is emitted from is not cached
*/
public get member() {
return this.ctx.val.member;
return safeUnwrap(this.ctx
.map(m => m.member)
.mapErr(i => i.member));
}
get message(): Message {
return this.ctx.expect(SernError.MismatchEvent);
}
get interaction(): ChatInputCommandInteraction {
return this.ctx.expectErr(SernError.MismatchEvent);
}
public get client(): Client {
return this.ctx.val.client;
return safeUnwrap(this.ctx
.map(m => m.client)
.mapErr(i => i.client));
}
public get inGuild(): boolean {
return this.ctx.val.inGuild();
return safeUnwrap(this.ctx
.map(m => m.inGuild())
.mapErr(i => i.inGuild()));
}
public async reply(content: ReplyOptions) {
return safeUnwrap(
this.ctx
.map(m => m.reply(content as string | MessageReplyOptions))
.map(m => m.reply(content as MessageReplyOptions))
.mapErr(i =>
i.reply(content as string | InteractionReplyOptions).then(() => i.fetchReply()),
),
i.reply(content as InteractionReplyOptions).then(() => i.fetchReply())),
);
}
static override wrap(wrappable: BaseInteraction | Message): Context {
static wrap(wrappable: BaseInteraction | Message, prefix?: string): Context {
if ('interaction' in wrappable) {
return new Context(Ok(wrappable));
return new Context(Ok(wrappable), prefix);
}
assert.ok(wrappable.isChatInputCommand());
return new Context(Err(wrappable));
assert.ok(wrappable.isChatInputCommand(), "Context created with bad interaction.");
return new Context(Err(wrappable), prefix);
}
}
function safeUnwrap<T>(res: Result<T, T>) {
return res.val;
if(res.isOk()) {
return res.expect("Tried unwrapping message field: " + res)
}
return res.expectErr("Tried unwrapping interaction field" + res)
}

View File

@@ -1,5 +1,4 @@
import { Result as Either } from 'ts-results-es';
import { SernError } from '../_internal';
import * as assert from 'node:assert';
/**
@@ -7,26 +6,13 @@ import * as assert from 'node:assert';
*/
export abstract class CoreContext<M, I> {
protected constructor(protected ctx: Either<M, I>) {
assert.ok(typeof ctx.val === 'object' && ctx.val != null);
assert.ok(typeof ctx === 'object' && ctx != null, "Context was nonobject or null");
}
get message(): M {
return this.ctx.expect(SernError.MismatchEvent);
}
get interaction(): I {
return this.ctx.expectErr(SernError.MismatchEvent);
}
public isMessage(): this is CoreContext<M, never> {
return this.ctx.ok;
return this.ctx.isOk();
}
public isSlash(): this is CoreContext<never, I> {
return !this.isMessage();
}
//todo: add agnostic options resolver for Context
abstract get options(): unknown;
static wrap(_: unknown): unknown {
throw Error('You need to override this method; cannot wrap an abstract class');
}
}

View File

@@ -0,0 +1,89 @@
import { ScheduledTask } from '../../types/core-modules';
import type { LogPayload, Logging, ErrorHandling, Disposable } from '../interfaces';
import { CronJob } from 'cron';
/**
* @internal
* @since 2.0.0
* Version 4.0.0 will internalize this api. Please refrain from using the defaults!
*/
export class DefaultErrorHandling implements ErrorHandling {
crash(err: Error): never {
throw err;
}
updateAlive(err: Error) {
throw err;
}
}
/**
* @internal
* @since 2.0.0
* Version 4.0.0 will internalize this api. Please refrain from using ModuleStore!
*/
export class DefaultLogging implements Logging {
private date() { return new Date() }
debug(payload: LogPayload): void {
console.debug(`DEBUG: ${this.date().toISOString()} -> ${payload.message}`);
}
error(payload: LogPayload): void {
console.error(`ERROR: ${this.date().toISOString()} -> ${payload.message}`);
}
info(payload: LogPayload): void {
console.info(`INFO: ${this.date().toISOString()} -> ${payload.message}`);
}
warning(payload: LogPayload): void {
console.warn(`WARN: ${this.date().toISOString()} -> ${payload.message}`);
}
}
export class TaskScheduler implements Disposable {
private __tasks: Map<string, CronJob<any, any>> = new Map();
schedule(uuid: string, task: ScheduledTask, deps: Dependencies) {
if (this.__tasks.has(uuid)) {
throw Error("while scheduling a task \
found another task of same name. Not scheduling " +
uuid + "again." );
}
try {
const onTick = async function(this: CronJob) {
task.execute({ id: uuid,
lastTimeExecution: this.lastExecution,
nextTimeExecution: this.nextDate().toJSDate() }, { deps })
}
const job = CronJob.from({ cronTime: task.trigger, onTick, timeZone: task.timezone });
job.start();
this.__tasks.set(uuid, job);
} catch (error) {
throw Error(`while scheduling a task ${uuid} ` + error);
}
}
kill(taskName: string): boolean {
const job = this.__tasks.get(taskName);
if (job) {
job.stop();
this.__tasks.delete(taskName);
return true;
}
return false;
}
get tasks(): string[] {
return Array.from(this.__tasks.keys());
}
dispose() {
this.__tasks.forEach((_, id) => {
this.kill(id);
this.__tasks.delete(id);
})
}
}

View File

@@ -48,16 +48,16 @@ export enum EventType {
/**
* The EventType for handling discord events
*/
Discord = 1,
Discord,
/**
* The EventType for handling sern events
*/
Sern = 2,
Sern,
/**
* The EventType for handling external events.
* Could be for example, `process` events, database events
*/
External = 3,
External,
}
/**
@@ -85,20 +85,12 @@ export enum PluginType {
Control = 2,
}
/**
* @deprecated - Use strings 'success' | 'failure' | 'warning'
* @enum { string }
*/
export enum PayloadType {
/**
* The PayloadType for a SernEmitter success event
*/
Success = 'success',
/**
* The PayloadType for a SernEmitter failure event
*/
Failure = 'failure',
/**
* The PayloadType for a SernEmitter warning event
*/
Warning = 'warning',
}

View File

@@ -1,5 +0,0 @@
export { CommandType, PluginType, PayloadType, EventType } from './enums';
export * from './context';
export * from './sern-emitter';
export * from './services';
export * from './module-store';

View File

@@ -1,12 +0,0 @@
import { CommandMeta, Module } from '../../types/core-modules';
import { CoreModuleStore } from '../contracts';
/*
* @internal
* Version 4.0.0 will internalize this api. Please refrain from using ModuleStore!
* For interacting with modules, use the ModuleManager instead.
*/
export class ModuleStore implements CoreModuleStore {
metadata = new WeakMap<Module, CommandMeta>();
commands = new Map<string, string>();
}

View File

@@ -1,89 +0,0 @@
import { EventEmitter } from 'node:events';
import { PayloadType } from '../../core/structures';
import { Module } from '../../types/core-modules';
import { SernEventsMapping, Payload } from '../../types/utility';
/**
* @since 1.0.0
*/
export class SernEmitter extends EventEmitter {
constructor() {
super({ captureRejections: true });
}
/**
* Listening to sern events with on. This event stays on until a crash or a normal exit
* @param eventName
* @param listener what to do with the data
*/
public override on<T extends keyof SernEventsMapping>(
eventName: T,
listener: (...args: SernEventsMapping[T][]) => void,
): this {
return super.on(eventName, listener);
}
/**
* Listening to sern events with on. This event stays on until a crash or a normal exit
* @param eventName
* @param listener what to do with the data
*/
public override once<T extends keyof SernEventsMapping>(
eventName: T,
listener: (...args: SernEventsMapping[T][]) => void,
): this {
return super.once(eventName, listener);
}
/**
* Listening to sern events with on. This event stays on until a crash or a normal exit
* @param eventName
* @param args the arguments for emitting the eventName
*/
public override emit<T extends keyof SernEventsMapping>(
eventName: T,
...args: SernEventsMapping[T]
): boolean {
return super.emit(eventName, ...args);
}
private static payload<T extends 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<Payload & { type: PayloadType.Failure }>(
PayloadType.Failure,
module,
reason,
);
}
/**
* Creates a compliant SernEmitter module success payload
* @param module
*/
static success(module: Module) {
return SernEmitter.payload<Payload & { type: PayloadType.Success }>(
PayloadType.Success,
module,
);
}
/**
* Creates a compliant SernEmitter module warning payload
* @param reason
*/
static warning(reason: unknown) {
return SernEmitter.payload<Payload & { type: PayloadType.Warning }>(
PayloadType.Warning,
undefined,
reason,
);
}
}

View File

@@ -1,21 +0,0 @@
import { ErrorHandling } from '../../contracts';
/**
* @internal
* @since 2.0.0
* Version 4.0.0 will internalize this api. Please refrain from using ModuleStore!
*/
export class DefaultErrorHandling implements ErrorHandling {
crash(err: Error): never {
throw err;
}
keepAlive = 5;
updateAlive(err: Error) {
this.keepAlive--;
if (this.keepAlive === 0) {
throw err;
}
}
}

View File

@@ -1,3 +0,0 @@
export * from './error-handling';
export * from './logger';
export * from './module-manager';

View File

@@ -1,25 +0,0 @@
import { LogPayload, Logging } from '../../contracts';
/**
* @internal
* @since 2.0.0
* Version 4.0.0 will internalize this api. Please refrain from using ModuleStore!
*/
export class DefaultLogging implements Logging {
private date = () => new Date();
debug(payload: LogPayload): void {
console.debug(`DEBUG: ${this.date().toISOString()} -> ${payload.message}`);
}
error(payload: LogPayload): void {
console.error(`ERROR: ${this.date().toISOString()} -> ${payload.message}`);
}
info(payload: LogPayload): void {
console.info(`INFO: ${this.date().toISOString()} -> ${payload.message}`);
}
warning(payload: LogPayload): void {
console.warn(`WARN: ${this.date().toISOString()} -> ${payload.message}`);
}
}

View File

@@ -1,50 +0,0 @@
import * as Id from '../../../core/id';
import { CoreModuleStore, ModuleManager } from '../../contracts';
import { Files } from '../../_internal';
import { CommandMeta, CommandModule, CommandModuleDefs, Module } from '../../../types/core-modules';
import { CommandType } from '../enums';
/**
* @internal
* @since 2.0.0
* Version 4.0.0 will internalize this api. Please refrain from using DefaultModuleManager!
*/
export class DefaultModuleManager implements ModuleManager {
constructor(private moduleStore: CoreModuleStore) {}
getByNameCommandType<T extends CommandType>(name: string, commandType: T) {
const id = this.get(Id.create(name, commandType));
if (!id) {
return undefined;
}
return Files.importModule<CommandModuleDefs[T]>(id);
}
setMetadata(m: Module, c: CommandMeta): void {
this.moduleStore.metadata.set(m, c);
}
getMetadata(m: Module): CommandMeta {
const maybeModule = this.moduleStore.metadata.get(m);
if (!maybeModule) {
throw Error('Could not find metadata in store for ' + m);
}
return maybeModule;
}
get(id: string) {
return this.moduleStore.commands.get(id);
}
set(id: string, path: string): void {
this.moduleStore.commands.set(id, path);
}
//not tested
getPublishableCommands(): Promise<CommandModule[]> {
const entries = this.moduleStore.commands.entries();
const publishable = 0b000000110;
return Promise.all(
Array.from(entries)
.filter(([id]) => !(Number.parseInt(id.at(-1)!) & publishable))
.map(([, path]) => Files.importModule<CommandModule>(path)),
);
}
}

View File

@@ -1,2 +0,0 @@
export * from './dispatchers';
export * from './event-utils';

View File

@@ -1,117 +0,0 @@
import { EventEmitter } from 'node:events';
import * as assert from 'node:assert';
import { concatMap, from, fromEvent, map, OperatorFunction, pipe } from 'rxjs';
import {
arrayifySource,
callPlugin,
isAutocomplete,
treeSearch,
SernError,
} from '../core/_internal';
import { createResultResolver } from './event-utils';
import { AutocompleteInteraction, BaseInteraction, Message } from 'discord.js';
import { CommandType, Context } from '../core';
import type { Args } from '../types/utility';
import type { BothCommand, CommandModule, Module, Processed } from '../types/core-modules';
function dispatchInteraction<T extends CommandModule, V extends BaseInteraction | Message>(
payload: { module: Processed<T>; event: V },
createArgs: (m: typeof payload.event) => unknown[],
) {
return {
module: payload.module,
args: createArgs(payload.event),
};
}
//TODO: refactor dispatchers so that it implements a strategy for each different type of payload?
export function dispatchMessage(module: Processed<CommandModule>, args: [Context, Args]) {
return {
module,
args,
};
}
function dispatchAutocomplete(payload: {
module: Processed<BothCommand>;
event: AutocompleteInteraction;
}) {
const option = treeSearch(payload.event, payload.module.options);
assert.ok(
option,
Error(SernError.NotSupportedInteraction + ` There is no autocomplete tag for this option`),
);
return {
module: option.command as Processed<Module>, //autocomplete is not a true "module" warning cast!
args: [payload.event],
};
}
export function contextArgs(wrappable: Message | BaseInteraction, messageArgs?: string[]) {
const ctx = Context.wrap(wrappable);
const args = ctx.isMessage() ? ['text', messageArgs!] : ['slash', ctx.options];
return [ctx, args] as [Context, Args];
}
function interactionArg<T extends BaseInteraction>(interaction: T) {
return [interaction] as [T];
}
function intoPayload(module: Processed<Module>) {
return pipe(
arrayifySource,
map(args => ({ module, args })),
);
}
const createResult = createResultResolver<
Processed<Module>,
{ module: Processed<Module>; args: unknown[] },
unknown[]
>({
createStream: ({ module, args }) => from(module.onEvent).pipe(callPlugin(args)),
onNext: ({ args }) => args,
});
/**
* Creates an observable from { source }
* @param module
* @param source
*/
export function eventDispatcher(module: Processed<Module>, source: unknown) {
assert.ok(source instanceof EventEmitter, `${source} is not an EventEmitter`);
const execute: OperatorFunction<unknown[], unknown> = concatMap(async args =>
module.execute(...args),
);
return fromEvent(source, module.name).pipe(
intoPayload(module),
concatMap(createResult),
execute,
);
}
export function createDispatcher(payload: {
module: Processed<CommandModule>;
event: BaseInteraction;
}) {
assert.ok(
CommandType.Text !== payload.module.type,
SernError.MismatchEvent + 'Found text command in interaction stream',
);
switch (payload.module.type) {
case CommandType.Slash:
case CommandType.Both: {
if (isAutocomplete(payload.event)) {
/**
* Autocomplete is a special case that
* must be handled separately, since it's
* too different from regular command modules
* CAST SAFETY: payload is already guaranteed to be a slash command or both command
*/
return dispatchAutocomplete(payload as never);
}
return dispatchInteraction(payload, contextArgs);
}
default:
return dispatchInteraction(payload, interactionArg);
}
}

View File

@@ -1,60 +1,125 @@
import { Interaction, Message } from 'discord.js';
import type { Interaction, Message, BaseInteraction } from 'discord.js';
import util from 'node:util';
import {
EMPTY,
Observable,
concatMap,
filter,
from,
of,
throwError,
tap,
MonoTypeOperatorFunction,
catchError,
finalize,
EMPTY, type Observable, concatMap, filter,
throwError, fromEvent, map, type OperatorFunction,
catchError, finalize, pipe, from, take, share, of,
} from 'rxjs';
import {
Files,
Id,
callPlugin,
everyPluginOk,
filterMapTo,
handleError,
SernError,
VoidResult,
} from '../core/_internal';
import { Emitter, ErrorHandling, Logging, ModuleManager, useContainerRaw } from '../core';
import { contextArgs, createDispatcher, dispatchMessage } from './dispatchers';
import { ObservableInput, pipe } from 'rxjs';
import { SernEmitter } from '../core';
import * as Id from '../core/id'
import type { Emitter, ErrorHandling, Logging } from '../core/interfaces';
import { SernError } from '../core/structures/enums'
import { Err, Ok, Result } from 'ts-results-es';
import type { Awaitable } from '../types/utility';
import type { ControlPlugin } from '../types/core-plugin';
import type { AnyModule, CommandModule, Module, Processed } from '../types/core-modules';
import type { ImportPayload } from '../types/core';
import type { UnpackedDependencies } from '../types/utility';
import type { CommandModule, Module, Processed } from '../types/core-modules';
import * as assert from 'node:assert';
import { Context } from '../core/structures/context';
import { CommandType } from '../core/structures/enums'
import { inspect } from 'node:util'
import { disposeAll } from '../core/ioc';
import { resultPayload, isAutocomplete, treeSearch, fmt } from '../core/functions'
import merge from 'deepmerge'
function handleError<C>(crashHandler: ErrorHandling, emitter: Emitter, logging?: Logging) {
return (pload: unknown, caught: Observable<C>) => {
// This is done to fit the ErrorHandling contract
if(!emitter.emit('error', pload)) {
const err = pload instanceof Error ? pload : Error(util.inspect(pload, { colors: true }));
logging?.error({ message: util.inspect(pload) });
crashHandler.updateAlive(err);
}
return caught;
};
}
const arrayify= <T>(src: T) =>
Array.isArray(src) ? src : [src];
interface ExecutePayload {
module: Module;
args: unknown[];
deps: Dependencies;
params?: string;
[key: string]: unknown
}
export const filterTap = <K, R>(onErr: (e: R) => void): OperatorFunction<Result<K, R>, K> =>
concatMap(result => {
if(result.isOk()) {
return of(result.value)
}
onErr(result.error);
return EMPTY;
})
export const sharedEventStream = <T>(e: Emitter, eventName: string) =>
(fromEvent(e, eventName) as Observable<T>).pipe(share());
function intoPayload(module: Module, deps: Dependencies) {
return pipe(map(arrayify),
map(args => ({ module, args, deps })),
map(p => p.args));
}
/**
* Creates an observable from { source }
* @param module
* @param source
*/
export function eventDispatcher(deps: Dependencies, module: Module, source: unknown) {
assert.ok(source && typeof source === 'object',
`${source} cannot be constructed into an event listener`);
const execute: OperatorFunction<unknown[]|undefined, unknown> =
concatMap(async args => {
if(args) return Reflect.apply(module.execute, null, args);
});
//@ts-ignore
let ev = fromEvent(source ,module.name!);
//@ts-ignore
if(module['once']) {
ev = ev.pipe(take(1))
}
return ev.pipe(intoPayload(module, deps),
execute);
}
interface DispatchPayload {
module: Processed<CommandModule>;
event: BaseInteraction;
defaultPrefix?: string;
deps: Dependencies;
params?: string
};
export function createDispatcher({ module, event, defaultPrefix, deps, params }: DispatchPayload): ExecutePayload {
assert.ok(CommandType.Text !== module.type,
SernError.MismatchEvent + 'Found text command in interaction stream');
if(isAutocomplete(event)) {
assert.ok(module.type === CommandType.Slash
|| module.type === CommandType.Both, "Autocomplete option on non command interaction");
const option = treeSearch(event, module.options);
assert.ok(option, SernError.NotSupportedInteraction + ` There is no autocomplete tag for ` + inspect(module));
const { command } = option;
return { module: command as Processed<Module>, //autocomplete is not a true "module" warning cast!
args: [event],
deps };
}
switch (module.type) {
case CommandType.Slash:
case CommandType.Both: {
return { module, args: [Context.wrap(event, defaultPrefix)], deps };
}
default: return { module, args: [event], deps, params };
}
}
function createGenericHandler<Source, Narrowed extends Source, Output>(
source: Observable<Source>,
makeModule: (event: Narrowed) => Promise<Output>,
) {
return (pred: (i: Source) => i is Narrowed) =>
source.pipe(
filter(pred),
concatMap(makeModule));
filter(pred), // only handle this stream if it passes pred
concatMap(makeModule)); // create a payload, preparing to execute
}
/**
* Removes the first character(s) _[depending on prefix length]_ of the message
* @param msg
* @param prefix The prefix to remove
* @returns The message without the prefix
* @example
* message.content = '!ping';
* console.log(fmt(message, '!'));
* // [ 'ping' ]
*/
export function fmt(msg: string, prefix: string): string[] {
return msg.slice(prefix.length).trim().split(/\s+/g);
}
/**
*
@@ -65,183 +130,141 @@ export function fmt(msg: string, prefix: string): string[] {
*/
export function createInteractionHandler<T extends Interaction>(
source: Observable<Interaction>,
mg: ModuleManager,
deps: Dependencies,
defaultPrefix?: string
) {
const mg = deps['@sern/modules'];
return createGenericHandler<Interaction, T, Result<ReturnType<typeof createDispatcher>, void>>(
source,
async event => {
const fullPath = mg.get(Id.reconstruct(event));
if(!fullPath) {
return Err.EMPTY
const possibleIds = Id.reconstruct(event);
let modules = possibleIds
.map(({ id, params }) => ({ module: mg.get(id), params }))
.filter(({ module }) => module !== undefined);
if(modules.length == 0) {
return Err.EMPTY;
}
return Files
.defaultModuleLoader<Processed<CommandModule>>(fullPath)
.then(payload =>
Ok(createDispatcher({ module: payload.module, event }))
);
},
);
const [{module, params}] = modules;
return Ok(createDispatcher({
module: module as Processed<CommandModule>,
event, defaultPrefix, deps, params
}));
});
}
export function createMessageHandler(
source: Observable<Message>,
defaultPrefix: string,
mg: ModuleManager,
deps: Dependencies,
) {
const mg = deps['@sern/modules'];
return createGenericHandler(source, async event => {
const [prefix, ...rest] = fmt(event.content, defaultPrefix);
const fullPath = mg.get(`${prefix}_A1`);
if(!fullPath) {
return Err('Possibly undefined behavior: could not find a static id to resolve ')
const [prefix] = fmt(event.content, defaultPrefix);
let module= mg.get(`${prefix}_T`) ?? mg.get(`${prefix}_B`) as Module;
if(!module) {
return Err('Possibly undefined behavior: could not find a static id to resolve');
}
return Files
.defaultModuleLoader<Processed<CommandModule>>(fullPath)
.then(payload => {
const args = contextArgs(event, rest);
return Ok(dispatchMessage(payload.module, args));
});
});
}
/**
* IMPURE SIDE EFFECT
* This function assigns remaining, incomplete data to each imported module.
*/
function assignDefaults<T extends Module>(
moduleManager: ModuleManager,
): MonoTypeOperatorFunction<ImportPayload<T>> {
return tap(({ module, absPath }) => {
module.name ??= Files.filename(absPath);
module.description ??= '...';
moduleManager.setMetadata(module, {
isClass: module.constructor.name === 'Function',
fullPath: absPath,
id: Id.create(module.name, module.type),
});
return Ok({ args: [Context.wrap(event, defaultPrefix)], module, deps })
});
}
export function buildModules<T extends AnyModule>(
input: ObservableInput<string>,
moduleManager: ModuleManager,
) {
return Files.buildModuleStream<Processed<T>>(input).pipe(assignDefaults(moduleManager));
}
/**
* 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
* thank u kingomes
* @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(
emitter: Emitter,
{
module,
task,
}: {
module: Processed<Module>;
task: () => Awaitable<unknown>;
},
) {
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));
export function executeModule(emitter: Emitter, { module, args }: ExecutePayload) {
return from(Result.wrapAsync(async () => module.execute(...args)))
.pipe(concatMap(result => {
if (result.isOk()) {
emitter.emit('module.activate', resultPayload('success', module));
return EMPTY;
} else {
return throwError(() => SernEmitter.failure(module, result.val));
}
}),
);
}
return throwError(() => resultPayload('failure', module, result.error));
}))
};
/**
* A higher order function that
* - creates a stream of {@link VoidResult} { config.createStream }
* - any failures results to { config.onFailure } being called
* - calls all control plugins.
* - any failures results to { config.onStop } being called
* - if all results are ok, the stream is converted to { config.onNext }
* emit config.onSuccess Observable
* config.onNext will be returned if everything is okay.
* @param config
* @returns receiver function for flattening a stream of data
* @returns function which calls all plugins and returns onNext or fail
*/
export function createResultResolver<
T extends { execute: (...args: any[]) => any; onEvent: ControlPlugin[] },
Args extends { module: T; [key: string]: unknown },
Output,
>(config: {
onStop?: (module: T) => unknown;
onNext: (args: Args) => Output;
createStream: (args: Args) => Observable<VoidResult>;
export function createResultResolver<Output>(config: {
onStop?: (module: Module, err?: string) => unknown;
onNext: (args: ExecutePayload, map: Record<string, unknown>) => Output;
}) {
return (args: Args) => {
const task$ = config.createStream(args);
return task$.pipe(
tap(result => {
result.err && config.onStop?.(args.module);
}),
everyPluginOk,
filterMapTo(() => config.onNext(args)),
);
const { onStop, onNext } = config;
return async (payload: ExecutePayload) => {
const task = await callPlugins(payload);
if (!task) throw Error("Plugin did not return anything.");
if(task.isOk()) {
return onNext(payload, task.value) as Output;
} else {
onStop?.(payload.module, String(task.error));
}
};
};
function isObject(item: unknown) {
return (item && typeof item === 'object' && !Array.isArray(item));
}
/**
* Calls a module's init plugins and checks for Err. If so, call { onStop } and
* ignore the module
*/
export function callInitPlugins<T extends Processed<AnyModule>>(sernEmitter: Emitter) {
return concatMap(
createResultResolver({
createStream: args => from(args.module.plugins).pipe(callPlugin(args)),
onStop: (module: T) => {
sernEmitter.emit(
'module.register',
SernEmitter.failure(module, SernError.PluginFailure),
);
},
onNext: ({ module }) => {
sernEmitter.emit('module.register', SernEmitter.success(module));
return module;
},
}),
);
//_module is frozen, preventing from mutations
export async function callInitPlugins(_module: Module, deps: Dependencies, emit?: boolean) {
let module = _module;
const emitter = deps['@sern/emitter'];
for(const plugin of module.plugins ?? []) {
const result = await plugin.execute({ module, absPath: module.meta.absPath, deps });
if (!result) throw Error("Plugin did not return anything. " + inspect(plugin, false, Infinity, true));
if(result.isErr()) {
if(emit) {
emitter?.emit('module.register',
resultPayload('failure', module, result.error ?? SernError.PluginFailure));
}
throw Error(result.error ?? SernError.PluginFailure);
}
}
return module
}
export async function callPlugins({ args, module, deps, params }: ExecutePayload) {
let state = {};
for(const plugin of module.onEvent??[]) {
const result = await plugin.execute(...args, { state, deps, params, type: module.type });
if(result.isErr()) {
return result;
}
if(isObject(result.value)) {
state = merge(state, result.value!);
}
}
return Ok(state);
}
/**
* Creates an executable task ( execute the command ) if all control plugins are successful
* Creates an executable task ( execute the command ) if all control plugins are successful
* @param onStop emits a failure response to the SernEmitter
*/
export function makeModuleExecutor<
M extends Processed<Module>,
Args extends { module: M; args: unknown[] },
>(onStop: (m: M) => unknown) {
const onNext = ({ args, module }: Args) => ({
task: () => module.execute(...args),
export function intoTask(onStop: (m: Module) => unknown) {
const onNext = ({ args, module, deps, params }: ExecutePayload, state: Record<string, unknown>) => ({
module,
args: [...args, { state, deps, params, type: module.type }],
deps
});
return concatMap(
createResultResolver({
onStop,
createStream: ({ args, module }) => from(module.onEvent).pipe(callPlugin(args)),
onNext,
}),
);
return createResultResolver({ onStop, onNext });
}
export const handleCrash = (err: ErrorHandling, log?: Logging) =>
pipe(
catchError(handleError(err, log)),
finalize(() => {
log?.info({
message: 'A stream closed or reached end of lifetime',
});
useContainerRaw()
?.disposeAll()
.then(() => log?.info({ message: 'Cleaning container and crashing' }));
}),
);
export const handleCrash = ({ "@sern/errors": err, '@sern/emitter': sem, '@sern/logger': log } : UnpackedDependencies, metadata: string) =>
pipe(catchError(handleError(err, sem, log)),
finalize(() => {
log?.info({ message: 'A stream closed: ' + metadata });
disposeAll(log);
}))

View File

@@ -1,33 +0,0 @@
import { Interaction } from 'discord.js';
import { concatMap, merge } from 'rxjs';
import { SernEmitter } from '../core';
import {
isAutocomplete,
isCommand,
isMessageComponent,
isModal,
sharedEventStream,
SernError,
filterTap,
} from '../core/_internal';
import { createInteractionHandler, executeModule, makeModuleExecutor } from './_internal';
import type { DependencyList } from '../types/ioc';
export function interactionHandler([emitter, , , modules, client]: DependencyList) {
const interactionStream$ = sharedEventStream<Interaction>(client, 'interactionCreate');
const handle = createInteractionHandler(interactionStream$, modules);
const interactionHandler$ = merge(
handle(isMessageComponent),
handle(isAutocomplete),
handle(isCommand),
handle(isModal),
);
return interactionHandler$
.pipe(
filterTap(e => emitter.emit('warning', SernEmitter.warning(e))),
makeModuleExecutor(module =>
emitter.emit('module.activate', SernEmitter.failure(module, SernError.PluginFailure))),
concatMap(payload => executeModule(emitter, payload)),
);
}

View File

@@ -0,0 +1,30 @@
import type { Interaction } from 'discord.js';
import { mergeMap, merge, concatMap, EMPTY } from 'rxjs';
import { createInteractionHandler, executeModule, intoTask, sharedEventStream, filterTap, handleCrash } from './event-utils';
import { SernError } from '../core/structures/enums'
import { isAutocomplete, isCommand, isMessageComponent, isModal, resultPayload } from '../core/functions'
import { UnpackedDependencies } from '../types/utility';
export default function interactionHandler(deps: UnpackedDependencies, defaultPrefix?: string) {
//i wish javascript had clojure destructuring
const { '@sern/client': client,
'@sern/emitter': emitter } = deps
const interactionStream$ = sharedEventStream<Interaction>(client, 'interactionCreate');
const handle = createInteractionHandler(interactionStream$, deps, defaultPrefix);
const interactionHandler$ = merge(handle(isMessageComponent),
handle(isAutocomplete),
handle(isCommand),
handle(isModal));
return interactionHandler$
.pipe(filterTap(e => emitter.emit('warning', resultPayload('warning', undefined, e))),
concatMap(intoTask(module => {
emitter.emit('module.activate', resultPayload('failure', module, SernError.PluginFailure))
})),
mergeMap(payload => {
if(payload)
return executeModule(emitter, payload)
return EMPTY;
}),
handleCrash(deps, "interaction handling"));
}

View File

@@ -1,47 +0,0 @@
import { concatMap, EMPTY } from 'rxjs';
import type { Message } from 'discord.js';
import { SernEmitter } from '../core';
import { sharedEventStream, SernError, filterTap } from '../core/_internal';
import { createMessageHandler, executeModule, makeModuleExecutor } from './_internal';
import type { DependencyList } from '../types/ioc';
/**
* Ignores messages from any person / bot except itself
* @param prefix
*/
function isNonBot(prefix: string) {
return (msg: Message): msg is Message => !msg.author.bot && hasPrefix(prefix, msg.content);
}
function hasPrefix(prefix: string, content: string) {
const prefixInContent = content.slice(0, prefix.length);
return (
prefixInContent.localeCompare(prefix, undefined, {
sensitivity: 'accent',
}) === 0
);
}
export function messageHandler(
[emitter, , log, modules, client]: DependencyList,
defaultPrefix: string | undefined,
) {
if (!defaultPrefix) {
log?.debug({
message: 'No prefix found. message handler shutting down',
});
return EMPTY;
}
const messageStream$ = sharedEventStream<Message>(client, 'messageCreate');
const handle = createMessageHandler(messageStream$, defaultPrefix, modules);
const msgCommands$ = handle(isNonBot(defaultPrefix));
return msgCommands$.pipe(
filterTap((e) => emitter.emit('warning', SernEmitter.warning(e))),
makeModuleExecutor(module => {
emitter.emit('module.activate', SernEmitter.failure(module, SernError.PluginFailure));
}),
concatMap(payload => executeModule(emitter, payload)),
);
}

50
src/handlers/message.ts Normal file
View File

@@ -0,0 +1,50 @@
import { EMPTY, mergeMap, concatMap } from 'rxjs';
import type { Message } from 'discord.js';
import { createMessageHandler, executeModule, intoTask, sharedEventStream, filterTap, handleCrash} from './event-utils';
import { SernError } from '../core/structures/enums'
import { resultPayload } from '../core/functions'
import { UnpackedDependencies } from '../types/utility';
import type { Emitter } from '../core/interfaces';
/**
* Ignores messages from any person / bot except itself
* @param prefix
*/
function isNonBot(prefix: string) {
return (msg: Message): msg is Message => !msg.author.bot && hasPrefix(prefix, msg.content);
}
function hasPrefix(prefix: string, content: string) {
const prefixInContent = content.slice(0, prefix.length);
return prefixInContent.localeCompare(prefix, undefined, { sensitivity: 'accent' }) === 0;
}
export default
function (deps: UnpackedDependencies, defaultPrefix?: string) {
const {"@sern/emitter": emitter,
'@sern/logger': log,
'@sern/client': client} = deps
if (!defaultPrefix) {
log?.debug({ message: 'No prefix found. message handler shutting down' });
return EMPTY;
}
const messageStream$ = sharedEventStream<Message>(client as unknown as Emitter, 'messageCreate');
const handle = createMessageHandler(messageStream$, defaultPrefix, deps);
const msgCommands$ = handle(isNonBot(defaultPrefix));
return msgCommands$.pipe(
filterTap(e => emitter.emit('warning', resultPayload('warning', undefined, e))),
concatMap(intoTask(module => {
const result = resultPayload('failure', module, SernError.PluginFailure);
emitter.emit('module.activate', result);
})),
mergeMap(payload => {
if(payload)
return executeModule(emitter, payload)
return EMPTY;
}),
handleCrash(deps, "message handling")
)
}

40
src/handlers/presence.ts Normal file
View File

@@ -0,0 +1,40 @@
import { concatMap, from, interval, of, map, scan, startWith, fromEvent, take } from "rxjs"
import { Presence } from "../core/presences";
import { Services } from "../core/ioc";
import assert from "node:assert";
import * as Files from "../core/module-loading";
type SetPresence = (conf: Presence.Result) => Promise<unknown>
const parseConfig = async (conf: Promise<Presence.Result>) => {
return conf.then(s => {
if('repeat' in s) {
const { onRepeat, repeat } = s;
assert(repeat !== undefined, "repeat option is undefined");
assert(onRepeat !== undefined, "onRepeat callback is undefined, but repeat exists");
const src$ = typeof repeat === 'number'
? interval(repeat)
: fromEvent(...repeat);
return src$.pipe(scan(onRepeat, s),
startWith(s));
}
return of(s).pipe(take(1));
})
};
export const presenceHandler = (path: string, setPresence: SetPresence) => {
const presence = Files
.importModule<Presence.Config<(keyof Dependencies)[]>>(path)
.then(({ module }) => {
//fetch services with the order preserved, passing it to the execute fn
const fetchedServices = Services(...module.inject ?? []);
return async () => module.execute(...fetchedServices);
})
const module$ = from(presence);
return module$.pipe(
//compose:
//call the execute function, passing that result into parseConfig.
//concatMap resolves the promise, and passes it to the next concatMap.
concatMap(fn => parseConfig(fn())),
// subscribe to the observable parseConfig yields, and set the presence.
concatMap(conf => conf.pipe(map(setPresence))));
}

View File

@@ -1,48 +0,0 @@
import { ObservableInput, concat, first, fromEvent, ignoreElements, pipe } from 'rxjs';
import { CommandType } from '../core/structures';
import { SernError } from '../core/_internal';
import { Result } from 'ts-results-es';
import { ModuleManager } from '../core/contracts';
import { buildModules, callInitPlugins } from './_internal';
import * as assert from 'node:assert';
import * as util from 'node:util';
import type { DependencyList } from '../types/ioc';
import type { AnyModule, Processed } from '../types/core-modules';
export function startReadyEvent(
[sEmitter, , , moduleManager, client]: DependencyList,
allPaths: ObservableInput<string>,
) {
const ready$ = fromEvent(client!, 'ready').pipe(once());
return concat(ready$, buildModules<AnyModule>(allPaths, moduleManager))
.pipe(callInitPlugins(sEmitter))
.subscribe(module => {
register(moduleManager, module).expect(
SernError.InvalidModuleType + ' ' + util.inspect(module),
);
});
}
const once = () => pipe(
first(),
ignoreElements()
)
function register<T extends Processed<AnyModule>>(
manager: ModuleManager,
module: T,
): Result<void, void> {
const { id, fullPath } = manager.getMetadata(module)!;
const validModuleType = module.type >= 0 && module.type <= 1 << 10;
assert.ok(
validModuleType,
`Found ${module.name} at ${fullPath}, which does not have a valid type`,
);
if (module.type === CommandType.Both || module.type === CommandType.Text) {
module.alias?.forEach(a => manager.set(`${a}_A1`, fullPath));
}
return Result.wrap(() => manager.set(id, fullPath));
}

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

@@ -0,0 +1,33 @@
import * as Files from '../core/module-loading'
import { once } from 'node:events';
import { resultPayload } from '../core/functions';
import { CommandType } from '../core/structures/enums';
import { Module } from '../types/core-modules';
import { UnpackedDependencies } from '../types/utility';
import { callInitPlugins } from './event-utils';
export default async function(dir: string, deps : UnpackedDependencies) {
const { '@sern/client': client,
'@sern/logger': log,
'@sern/emitter': sEmitter,
'@sern/modules': commands } = deps;
log?.info({ message: "Waiting on discord client to be ready..." })
await once(client, "ready");
log?.info({ message: "Client signaled ready, registering modules" });
// https://observablehq.com/@ehouais/multiple-promises-as-an-async-generator
// possibly optimize to concurrently import modules
for await (const [path, directoryPlugins] of Files.readRecursive(dir)) {
console.log(path, directoryPlugins)
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');
}

16
src/handlers/tasks.ts Normal file
View File

@@ -0,0 +1,16 @@
import * as Files from '../core/module-loading'
import { UnpackedDependencies } from "../types/utility";
import type { ScheduledTask } from "../types/core-modules";
import { relative } from "path";
import { fileURLToPath } from "url";
export const registerTasks = async (tasksPath: string, deps: UnpackedDependencies) => {
const taskManager = deps['@sern/scheduler']
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,37 +1,34 @@
import { ObservableInput, map, mergeAll } from 'rxjs';
import { EventType } from '../core/structures';
import { SernError } from '../core/_internal';
import { buildModules, callInitPlugins, handleCrash, eventDispatcher } from './_internal';
import { Service } from '../core/ioc';
import type { DependencyList } from '../types/ioc';
import type { CommandModule, EventModule, Processed } from '../types/core-modules';
import { EventType, SernError } from '../core/structures/enums';
import { callInitPlugins, eventDispatcher, handleCrash } from './event-utils'
import { EventModule, Module } from '../types/core-modules';
import * as Files from '../core/module-loading'
import type { UnpackedDependencies } from '../types/utility';
import { from, map, mergeAll } from 'rxjs';
export function eventsHandler(
[emitter, err, log, moduleManager, client]: DependencyList,
allPaths: ObservableInput<string>,
) {
//code smell
const intoDispatcher = (e: Processed<EventModule | CommandModule>) => {
switch (e.type) {
const intoDispatcher = (deps: UnpackedDependencies) =>
(module : EventModule) => {
switch (module.type) {
case EventType.Sern:
return eventDispatcher(e, emitter);
return eventDispatcher(deps, module, deps['@sern/emitter']);
case EventType.Discord:
return eventDispatcher(e, client);
return eventDispatcher(deps, module, deps['@sern/client']);
case EventType.External:
return eventDispatcher(e, Service(e.emitter));
default:
throw Error(SernError.InvalidModuleType + ' while creating event handler');
return eventDispatcher(deps, module, deps[module.emitter]);
default: throw Error(SernError.InvalidModuleType + ' while creating event handler');
}
};
buildModules<EventModule>(allPaths, moduleManager)
.pipe(
callInitPlugins(emitter),
map(intoDispatcher),
/**
* Where all events are turned on
*/
mergeAll(),
handleCrash(err, log),
)
.subscribe();
};
export default async function(deps: UnpackedDependencies, eventDir: string) {
const eventModules: EventModule[] = [];
for await (const [path, _] of Files.readRecursive(eventDir)) {
console.log(path, _)
let { module } = await Files.importModule<Module>(path);
await callInitPlugins(module, deps)
eventModules.push(module as EventModule);
}
from(eventModules)
.pipe(map(intoDispatcher(deps)),
mergeAll(), // all eventListeners are turned on
handleCrash(deps, "event modules"))
.subscribe();
}

View File

@@ -1,6 +1,7 @@
export * as Sern from './sern';
export * from './core';
export type {
Module,
CommandModule,
EventModule,
BothCommand,
@@ -24,30 +25,48 @@ export type {
SernOptionsData,
SernSubCommandData,
SernSubCommandGroupData,
SDT,
ScheduledTask
} from './types/core-modules';
export type {
Controller,
PluginResult,
InitPlugin,
ControlPlugin,
Plugin,
AnyEventPlugin,
AnyCommandPlugin,
AnyPlugin,
} from './types/core-plugin';
export type { Wrapper } from './types/core';
export type { Args, SlashOptions, Payload, SernEventsMapping } from './types/utility';
export type { Singleton, Transient, CoreDependencies, Initializable } from './types/ioc';
export type { Payload, SernEventsMapping } from './types/utility';
export {
commandModule,
eventModule,
discordEvent,
EventExecutable,
CommandExecutable,
scheduledTask
} from './core/modules';
export { controller } from './sern';
export * from './core/presences'
export * from './core/interfaces'
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

@@ -1,16 +1,27 @@
import { handleCrash } from './handlers/_internal';
import { err, ok, Files } from './core/_internal';
import { merge } from 'rxjs';
import { Services } from './core/ioc';
import { Wrapper } from './types/core';
import { eventsHandler } from './handlers/user-defined-events';
import { startReadyEvent } from './handlers/ready-event';
import { messageHandler } from './handlers/message-event';
import { interactionHandler } from './handlers/interaction-event';
//side effect: global container
import { useContainerRaw } from '@sern/ioc/global';
import callsites from 'callsites';
import * as Files from './core/module-loading';
import { merge } from 'rxjs';
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';
import { UnpackedDependencies } from './types/utility';
import type { Presence} from './core/presences';
import { registerTasks } from './handlers/tasks';
interface Wrapper {
commands: string;
defaultPrefix?: string;
events?: string;
tasks?: string;
}
/**
* @since 1.0.0
* @param wrapper Options to pass into sern.
* @param maybeWrapper Options to pass into sern.
* Function to start the handler up
* @example
* ```ts title="src/index.ts"
@@ -21,55 +32,40 @@ import { interactionHandler } from './handlers/interaction-event';
* ```
*/
export function init(maybeWrapper: Wrapper | 'file') {
export function init(maybeWrapper: Wrapper = { commands: "./dist/commands" }) {
const startTime = performance.now();
const wrapper = Files.loadConfig(maybeWrapper);
const dependencies = useDependencies();
const logger = dependencies[2],
errorHandler = dependencies[1];
const mode = isDevMode(wrapper.mode ?? process.env.MODE);
if (wrapper.events !== undefined) {
eventsHandler(dependencies, Files.getFullPathTree(wrapper.events, mode));
const deps = useContainerRaw().deps<UnpackedDependencies>();
if (maybeWrapper.events !== undefined) {
eventsHandler(deps, maybeWrapper.events)
.then(() => {
deps['@sern/logger']?.info({ message: "Events registered" });
});
} else {
deps['@sern/logger']?.info({ message: "No events registered" });
}
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
startReadyEvent(dependencies, Files.getFullPathTree(wrapper.commands, mode)).add(() => {
const time = ((performance.now() - startTime) / 1000).toFixed(2);
dependencies[0].emit('modulesLoaded');
logger?.info({
message: `sern: registered all modules in ${time} s`,
});
});
ready(maybeWrapper.commands, deps)
.then(() => {
const time = ((performance.now() - startTime) / 1000).toFixed(2);
deps['@sern/logger']?.info({ message: `sern: registered in ${time} s` });
if(presencePath.exists) {
const setPresence = async (p: Presence.Result) => {
return deps['@sern/client'].user?.setPresence(p);
}
presenceHandler(presencePath.path, setPresence).subscribe();
}
if(maybeWrapper.tasks) {
registerTasks(maybeWrapper.tasks, deps);
}
})
.catch(err => { throw err });
const messages$ = messageHandler(dependencies, wrapper.defaultPrefix);
const interactions$ = interactionHandler(dependencies);
const messages$ = messageHandler(deps, maybeWrapper.defaultPrefix);
const interactions$ = interactionHandler(deps, maybeWrapper.defaultPrefix);
// listening to the message stream and interaction stream
merge(messages$, interactions$).pipe(handleCrash(errorHandler, logger)).subscribe();
merge(messages$, interactions$).subscribe();
}
function isDevMode(mode: string | undefined) {
console.info(`Detected mode: "${mode}"`);
if (mode === undefined) {
console.info('No mode found in process.env, assuming DEV');
}
return mode === 'DEV' || mode == undefined;
}
function useDependencies() {
return Services(
'@sern/emitter',
'@sern/errors',
'@sern/logger',
'@sern/modules',
'@sern/client',
);
}
/**
* @since 1.0.0
* The object passed into every plugin to control a command's behavior
*/
export const controller = {
next: ok,
stop: err,
};

View File

@@ -14,16 +14,20 @@ import type {
StringSelectMenuInteraction,
UserContextMenuCommandInteraction,
UserSelectMenuInteraction,
ChatInputCommandInteraction,
} from 'discord.js';
import { CommandType, Context, EventType } from '../../src/core';
import { AnyCommandPlugin, AnyEventPlugin, ControlPlugin, InitPlugin } from './core-plugin';
import { Awaitable, Args, SlashOptions, SernEventsMapping } from './utility';
import type { CommandType, EventType } from '../core/structures/enums';
import { Context } from '../core/structures/context'
import { ControlPlugin, InitPlugin, Plugin } from './core-plugin';
import { Awaitable, SernEventsMapping, UnpackedDependencies } from './utility';
export interface CommandMeta {
fullPath: string;
id: string;
isClass: boolean;
}
//state, deps, type (very original)
export type SDT = {
state: Record<string,unknown>;
deps: Dependencies;
type: CommandType,
params?: string
};
export type Processed<T> = T & { name: string; description: string };
@@ -33,6 +37,11 @@ export interface Module {
onEvent: ControlPlugin[];
plugins: InitPlugin[];
description?: string;
meta: {
id: string;
absPath: string;
}
locals: Record<string,unknown>
execute(...args: any[]): Awaitable<any>;
}
@@ -42,6 +51,7 @@ 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;
@@ -49,55 +59,55 @@ export interface ExternalEventCommand extends Module {
execute(...args: unknown[]): Awaitable<unknown>;
}
export interface ContextMenuUser extends Module {
type: CommandType.CtxUser;
execute: (ctx: UserContextMenuCommandInteraction) => Awaitable<unknown>;
execute: (ctx: UserContextMenuCommandInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface ContextMenuMsg extends Module {
type: CommandType.CtxMsg;
execute: (ctx: MessageContextMenuCommandInteraction) => Awaitable<unknown>;
execute: (ctx: MessageContextMenuCommandInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface ButtonCommand extends Module {
type: CommandType.Button;
execute: (ctx: ButtonInteraction) => Awaitable<unknown>;
execute: (ctx: ButtonInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface StringSelectCommand extends Module {
type: CommandType.StringSelect;
execute: (ctx: StringSelectMenuInteraction) => Awaitable<unknown>;
execute: (ctx: StringSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface ChannelSelectCommand extends Module {
type: CommandType.ChannelSelect;
execute: (ctx: ChannelSelectMenuInteraction) => Awaitable<unknown>;
execute: (ctx: ChannelSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface RoleSelectCommand extends Module {
type: CommandType.RoleSelect;
execute: (ctx: RoleSelectMenuInteraction) => Awaitable<unknown>;
execute: (ctx: RoleSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface MentionableSelectCommand extends Module {
type: CommandType.MentionableSelect;
execute: (ctx: MentionableSelectMenuInteraction) => Awaitable<unknown>;
execute: (ctx: MentionableSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface UserSelectCommand extends Module {
type: CommandType.UserSelect;
execute: (ctx: UserSelectMenuInteraction) => Awaitable<unknown>;
execute: (ctx: UserSelectMenuInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface ModalSubmitCommand extends Module {
type: CommandType.Modal;
execute: (ctx: ModalSubmitInteraction) => Awaitable<unknown>;
execute: (ctx: ModalSubmitInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface AutocompleteCommand
extends Omit<Module, 'name' | 'type' | 'plugins' | 'description'> {
onEvent: ControlPlugin[];
execute: (ctx: AutocompleteInteraction) => Awaitable<unknown>;
export interface AutocompleteCommand {
onEvent?: ControlPlugin[];
execute: (ctx: AutocompleteInteraction, tbd: SDT) => Awaitable<unknown>;
}
export interface DiscordEventCommand<T extends keyof ClientEvents = keyof ClientEvents>
@@ -108,26 +118,24 @@ export interface DiscordEventCommand<T extends keyof ClientEvents = keyof Client
}
export interface TextCommand extends Module {
type: CommandType.Text;
alias?: string[];
execute: (ctx: Context, args: ['text', string[]]) => Awaitable<unknown>;
execute: (ctx: Context & { get options(): string[] }, tbd: SDT) => Awaitable<unknown>;
}
export interface SlashCommand extends Module {
type: CommandType.Slash;
description: string;
options?: SernOptionsData[];
execute: (ctx: Context, args: ['slash', SlashOptions]) => Awaitable<unknown>;
execute: (ctx: Context & { get options(): ChatInputCommandInteraction['options']}, tbd: SDT) => Awaitable<unknown>;
}
export interface BothCommand extends Module {
type: CommandType.Both;
alias?: string[];
description: string;
options?: SernOptionsData[];
execute: (ctx: Context, args: Args) => Awaitable<unknown>;
execute: (ctx: Context, tbd: SDT) => Awaitable<unknown>;
}
export type EventModule = DiscordEventCommand | SernEventCommand | ExternalEventCommand;
export type EventModule = DiscordEventCommand | SernEventCommand | ExternalEventCommand;
export type CommandModule =
| TextCommand
| SlashCommand
@@ -142,7 +150,6 @@ export type CommandModule =
| RoleSelectCommand
| ModalSubmitCommand;
export type AnyModule = CommandModule | EventModule;
//https://stackoverflow.com/questions/64092736/alternative-to-switch-statement-for-typescript-discriminated-union
// Explicit Module Definitions for mapping
export interface CommandModuleDefs {
@@ -177,19 +184,22 @@ export interface SernAutocompleteData
}
type CommandModuleNoPlugins = {
[T in CommandType]: Omit<CommandModuleDefs[T], 'plugins' | 'onEvent'>;
[T in CommandType]: Omit<CommandModuleDefs[T], 'plugins' | 'onEvent' | 'meta' | 'locals'>;
};
type EventModulesNoPlugins = {
[T in EventType]: Omit<EventModuleDefs[T], 'plugins' | 'onEvent'>;
[T in EventType]: Omit<EventModuleDefs[T], 'plugins' | 'onEvent' | 'meta' | 'locals'> ;
};
export type InputEvent = {
[T in EventType]: EventModulesNoPlugins[T] & { plugins?: AnyEventPlugin[] };
[T in EventType]: EventModulesNoPlugins[T] & {
once?: boolean;
plugins?: InitPlugin[]
};
}[EventType];
export type InputCommand = {
[T in CommandType]: CommandModuleNoPlugins[T] & {
plugins?: AnyCommandPlugin[];
plugins?: Plugin[];
};
}[CommandType];
@@ -212,3 +222,37 @@ export interface SernSubCommandGroupData extends BaseApplicationCommandOptionsDa
type: ApplicationCommandOptionType.SubcommandGroup;
options?: SernSubCommandData[];
}
export interface ScheduledTaskContext {
/**
* the uuid of the current task being run
*/
id: string;
/**
* the last time this task was executed. If this is the first time, it is null.
*/
lastTimeExecution: Date | null;
/**
* The next time this task will be executed.
*/
nextTimeExecution: Date | null;
}
//name subject to change
interface TaskAttrs {
/**
* An object of dependencies configured in `makeDependencies`
*/
deps: UnpackedDependencies
}
export interface ScheduledTask {
name?: string;
trigger: string | Date;
timezone?: string;
execute(tasks: ScheduledTaskContext, sdt: TaskAttrs): Awaitable<void>
}

View File

@@ -11,34 +11,18 @@
* Plugins are reminiscent of middleware in express.
*/
import type { Err, Ok, Result } from 'ts-results-es';
import type { Result } from 'ts-results-es';
import type {
BothCommand,
ButtonCommand,
ChannelSelectCommand,
CommandModule,
ContextMenuMsg,
ContextMenuUser,
DiscordEventCommand,
EventModule,
ExternalEventCommand,
MentionableSelectCommand,
ModalSubmitCommand,
Module,
Processed,
RoleSelectCommand,
SernEventCommand,
SlashCommand,
StringSelectCommand,
TextCommand,
UserSelectCommand,
SDT,
} from './core-modules';
import { Args, Awaitable, Payload, SlashOptions } from './utility';
import { CommandType, Context, EventType, PluginType } from '../core';
import {
import type { Awaitable } from './utility';
import type { CommandType, PluginType } from '../core/structures/enums'
import type { Context } from '../core/structures/context'
import type {
ButtonInteraction,
ChannelSelectMenuInteraction,
ClientEvents,
MentionableSelectMenuInteraction,
MessageContextMenuCommandInteraction,
ModalSubmitInteraction,
@@ -48,106 +32,40 @@ import {
UserSelectMenuInteraction,
} from 'discord.js';
export type PluginResult = Awaitable<VoidResult>;
export type VoidResult = Result<void, void>;
export interface InitArgs<T extends Processed<Module>> {
export type PluginResult = Awaitable<Result<Record<string,unknown>|undefined, string|undefined>>;
export interface InitArgs<T extends Processed<Module> = Processed<Module>> {
module: T;
absPath: string;
}
export interface Controller {
next: () => Ok<void>;
stop: () => Err<void>;
deps: Dependencies
}
export interface Plugin<Args extends any[] = any[]> {
type: PluginType;
execute: (...args: Args) => PluginResult;
}
export interface InitPlugin<Args extends any[] = any[]> {
export interface InitPlugin<Args extends any[] = any[]> extends Plugin<Args> {
type: PluginType.Init;
execute: (...args: Args) => PluginResult;
}
export interface ControlPlugin<Args extends any[] = any[]> {
export interface ControlPlugin<Args extends any[] = any[]> extends Plugin<Args> {
type: PluginType.Control;
execute: (...args: Args) => PluginResult;
}
export type AnyCommandPlugin = ControlPlugin | InitPlugin<[InitArgs<Processed<CommandModule>>]>;
export type AnyEventPlugin = ControlPlugin | InitPlugin<[InitArgs<Processed<EventModule>>]>;
export type AnyPlugin = ControlPlugin | InitPlugin<[InitArgs<Processed<Module>>]>;
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];
export type CommandArgs<I extends CommandType = CommandType> = CommandArgsMatrix[I]
interface CommandArgsMatrix {
[CommandType.Text]: {
[PluginType.Control]: [Context, ['text', string[]]];
[PluginType.Init]: [InitArgs<Processed<TextCommand>>];
};
[CommandType.Slash]: {
[PluginType.Control]: [Context, ['slash', /* library coupled */ SlashOptions]];
[PluginType.Init]: [InitArgs<Processed<SlashCommand>>];
};
[CommandType.Both]: {
[PluginType.Control]: [Context, Args];
[PluginType.Init]: [InitArgs<Processed<BothCommand>>];
};
[CommandType.CtxMsg]: {
[PluginType.Control]: [/* library coupled */ MessageContextMenuCommandInteraction];
[PluginType.Init]: [InitArgs<Processed<ContextMenuMsg>>];
};
[CommandType.CtxUser]: {
[PluginType.Control]: [/* library coupled */ UserContextMenuCommandInteraction];
[PluginType.Init]: [InitArgs<Processed<ContextMenuUser>>];
};
[CommandType.Button]: {
[PluginType.Control]: [/* library coupled */ ButtonInteraction];
[PluginType.Init]: [InitArgs<Processed<ButtonCommand>>];
};
[CommandType.StringSelect]: {
[PluginType.Control]: [/* library coupled */ StringSelectMenuInteraction];
[PluginType.Init]: [InitArgs<Processed<StringSelectCommand>>];
};
[CommandType.RoleSelect]: {
[PluginType.Control]: [/* library coupled */ RoleSelectMenuInteraction];
[PluginType.Init]: [InitArgs<Processed<RoleSelectCommand>>];
};
[CommandType.ChannelSelect]: {
[PluginType.Control]: [/* library coupled */ ChannelSelectMenuInteraction];
[PluginType.Init]: [InitArgs<Processed<ChannelSelectCommand>>];
};
[CommandType.MentionableSelect]: {
[PluginType.Control]: [/* library coupled */ MentionableSelectMenuInteraction];
[PluginType.Init]: [InitArgs<Processed<MentionableSelectCommand>>];
};
[CommandType.UserSelect]: {
[PluginType.Control]: [/* library coupled */ UserSelectMenuInteraction];
[PluginType.Init]: [InitArgs<Processed<UserSelectCommand>>];
};
[CommandType.Modal]: {
[PluginType.Control]: [/* library coupled */ ModalSubmitInteraction];
[PluginType.Init]: [InitArgs<Processed<ModalSubmitCommand>>];
};
}
interface EventArgsMatrix {
[EventType.Discord]: {
[PluginType.Control]: /* library coupled */ ClientEvents[keyof ClientEvents];
[PluginType.Init]: [InitArgs<Processed<DiscordEventCommand>>];
};
[EventType.Sern]: {
[PluginType.Control]: [Payload];
[PluginType.Init]: [InitArgs<Processed<SernEventCommand>>];
};
[EventType.External]: {
[PluginType.Control]: unknown[];
[PluginType.Init]: [InitArgs<Processed<ExternalEventCommand>>];
};
[CommandType.Text]: [Context, SDT];
[CommandType.Slash]: [Context, SDT];
[CommandType.Both]: [Context, SDT];
[CommandType.CtxMsg]: [MessageContextMenuCommandInteraction, SDT];
[CommandType.CtxUser]: [UserContextMenuCommandInteraction, SDT];
[CommandType.Button]: [ButtonInteraction, SDT];
[CommandType.StringSelect]: [StringSelectMenuInteraction, SDT];
[CommandType.RoleSelect]: [RoleSelectMenuInteraction, SDT];
[CommandType.ChannelSelect]: [ChannelSelectMenuInteraction, SDT];
[CommandType.MentionableSelect]: [MentionableSelectMenuInteraction, SDT];
[CommandType.UserSelect]: [UserSelectMenuInteraction, SDT];
[CommandType.Modal]: [ModalSubmitInteraction, SDT];
}

View File

@@ -1,21 +0,0 @@
export interface ImportPayload<T> {
module: T;
absPath: string;
[key: string]: unknown;
}
export interface Wrapper {
commands: string;
defaultPrefix?: string;
events?: string;
/**
* Overload to enable mode in case developer does not use a .env file.
*/
mode?: 'DEV' | 'PROD';
/*
* @deprecated
*/
containerConfig?: {
get: (...keys: (keyof Dependencies)[]) => unknown[];
};
}

View File

@@ -4,9 +4,24 @@
/* eslint-disable @typescript-eslint/consistent-type-imports */
import { CoreDependencies } from './ioc';
import { CoreDependencies } from '../core/ioc';
declare global {
/**
* discord.js client.
* '@sern/client': Client
* sern emitter listens to events that happen throughout
* the handler. some include module.register, module.activate.
* '@sern/emitter': Contracts.Emitter;
* An error handler which is the final step before
* the sern process actually crashes.
'@sern/errors': Contracts.ErrorHandling;
* Optional logger. Performs ... logging
* '@sern/logger'?: Contracts.Logging;
* Readonly module store. sern stores these
* by module.meta.id -> Module
* '@sern/modules': Map<string, Module>;
*/
interface Dependencies extends CoreDependencies {}
}

View File

@@ -1,48 +0,0 @@
import { Container, UnpackFunction } from 'iti';
import * as Contracts from '../core/contracts';
/**
* 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;
/**
* Type to annotate that something is initializable.
* If T has an init method, this will be called.
*/
export type Initializable<T extends Contracts.Init> = T
export type DependencyList = [
Contracts.Emitter,
Contracts.ErrorHandling,
Contracts.Logging | undefined,
Contracts.ModuleManager,
Contracts.Emitter,
];
export interface CoreDependencies {
'@sern/client': () => Contracts.Emitter;
'@sern/logger'?: () => Contracts.Logging;
'@sern/emitter': () => Contracts.Emitter;
'@sern/store': () => Contracts.CoreModuleStore;
'@sern/modules': () => Contracts.ModuleManager;
'@sern/errors': () => Contracts.ErrorHandling;
}
export type DependencyFromKey<T extends keyof Dependencies> = Dependencies[T];
export type IntoDependencies<Tuple extends [...any[]]> = {
[Index in keyof Tuple]: UnpackFunction<NonNullable<DependencyFromKey<Tuple[Index]>>>; //Unpack and make NonNullable
} & { length: Tuple['length'] };
export interface DependencyConfiguration {
//@deprecated. Loggers will always be included in the future
exclude?: Set<'@sern/logger'>;
build: (
root: Container<Omit<CoreDependencies, '@sern/client'>, {}>,
) => Container<Dependencies, {}>;
}

View File

@@ -1,29 +1,27 @@
import { CommandInteractionOptionResolver } from 'discord.js';
import { PayloadType } from '../core';
import { AnyModule } from './core-modules';
import type { InteractionReplyOptions, MessageReplyOptions } from 'discord.js';
import type { Module } from './core-modules';
import type { Result } from 'ts-results-es';
export type Awaitable<T> = PromiseLike<T> | T;
export type AnyFunction = (...args: unknown[]) => unknown;
// Thanks to @kelsny
type ParseType<T> = {
[K in keyof T]: T[K] extends unknown ? [k: K, args: T[K]] : never;
}[keyof T];
export type SlashOptions = Omit<CommandInteractionOptionResolver, 'getMessage' | 'getFocused'>;
export type Args = ParseType<{ text: string[]; slash: SlashOptions }>;
export type VoidResult = Result<void, void>;
export type AnyFunction = (...args: any[]) => unknown;
export interface SernEventsMapping {
'module.register': [Payload];
'module.activate': [Payload];
error: [Payload];
error: [{ type: 'failure'; module?: Module; reason: string | Error }];
warning: [Payload];
modulesLoaded: [never?];
}
export type Payload =
| { type: PayloadType.Success; module: AnyModule }
| { type: PayloadType.Failure; module?: AnyModule; reason: string | Error }
| { type: PayloadType.Warning; reason: string };
| { type: 'success'; module: Module }
| { type: 'failure'; module?: Module; reason: string | Error }
| { type: 'warning'; module: undefined; reason: string };
export type UnpackFunction<T> = T extends (...args: any) => infer U ? U : T
export type UnpackedDependencies = {
[K in keyof Dependencies]: UnpackFunction<Dependencies[K]>
}
export type ReplyOptions = string | Omit<InteractionReplyOptions, 'fetchReply'> | MessageReplyOptions;

85
test/core/context.test.ts Normal file
View File

@@ -0,0 +1,85 @@
import { describe, vi, it, expect } from'vitest'
import { Context } from '../../src';
import { faker } from '@faker-js/faker'
describe('Context', () => {
// Mocked message and interaction objects for testing
const mockMessage = {
id: 'messageId',
channel: 'channelId',
channelId: 'channelId',
interaction: {
id: faker.string.uuid()
},
author: { id: 'userId' },
createdTimestamp: 1234567890,
guild: 'guildId',
guildId: 'guildId',
member: { id: 'memberId' },
client: { id: 'clientId' },
inGuild: vi.fn().mockReturnValue(true),
reply: vi.fn(),
};
const mockInteraction = {
id: 'interactionId',
user: { id: 'userId' },
channel: 'channelId',
channelId: 'channelId',
createdTimestamp: 1234567890,
guild: 'guildId',
guildId: 'guildId',
fetchReply: vi.fn().mockResolvedValue({}),
member: { id: 'memberId' },
client: { id: 'clientId' },
isChatInputCommand: vi.fn().mockResolvedValue(true),
inGuild: vi.fn().mockReturnValue(true),
reply: vi.fn().mockResolvedValue({}),
};
it('should create a context from a message', () => {
//@ts-ignore
const context = Context.wrap(mockMessage);
expect(context).toBeDefined();
expect(context.id).toBe('messageId');
});
it('should throw error if accessing interaction as message', () => {
//@ts-ignore
const context = Context.wrap(mockMessage);
expect(context).toBeDefined();
expect(() => context.interaction)
.toThrowError('You cannot use message when an interaction fired or vice versa');
})
it('should throw error if accessing message as interaction', () => {
//@ts-ignore
const context = Context.wrap(mockInteraction);
expect(context).toBeDefined();
expect(() => context.message)
.toThrowError('You cannot use message when an interaction fired or vice versa');
})
it('should create a context from an interaction', () => {
//@ts-ignore
const context = Context.wrap(mockInteraction);
expect(context).toBeDefined();
expect(context.id).toBe('interactionId');
});
it('should reply to a context with a message', async () => {
//@ts-ignore
const context = Context.wrap(mockMessage);
const replyOptions = { content: 'Hello, world!' };
await context.reply(replyOptions);
expect(mockMessage.reply).toHaveBeenCalledWith(replyOptions);
});
it('should reply to a context with an interaction', async () => {
//@ts-ignore
const context = Context.wrap(mockInteraction);
const replyOptions = { content: 'Hello, world!' };
await context.reply(replyOptions);
expect(mockInteraction.reply).toHaveBeenCalledWith(replyOptions);
});
});

View File

@@ -1,16 +1,11 @@
import { assertType, describe, it } from 'vitest';
import { ModuleStore } from '../../src';
import * as DefaultContracts from '../../src/core/structures/services';
import * as Contracts from '../../src/core/contracts/index.js';
import * as __Services from '../../src/core/structures/default-services';
import * as Contracts from '../../src/core/interfaces';
describe('default contracts', () => {
it('should satisfy contracts', () => {
assertType<Contracts.Logging>(new DefaultContracts.DefaultLogging());
assertType<Contracts.ErrorHandling>(new DefaultContracts.DefaultErrorHandling());
assertType<Contracts.ModuleManager>(
new DefaultContracts.DefaultModuleManager(new ModuleStore()),
);
assertType<Contracts.CoreModuleStore>(new ModuleStore());
assertType<Contracts.Logging>(new __Services.DefaultLogging());
assertType<Contracts.ErrorHandling>(new __Services.DefaultErrorHandling());
});
});

View File

@@ -2,17 +2,12 @@ import { describe, it, expect } from 'vitest';
import {
CommandControlPlugin,
CommandInitPlugin,
EventControlPlugin,
EventInitPlugin,
} from '../../src/core/create-plugins';
} from '../../src';
import { PluginType, controller } from '../../src';
describe('create-plugins', () => {
it('should make proper control plugins', () => {
const pl = EventControlPlugin(() => controller.next());
expect(pl).to.have.all.keys(['type', 'execute']);
expect(pl.type).toBe(PluginType.Control);
expect(pl.execute).an('function');
const pl2 = CommandControlPlugin(() => controller.next());
expect(pl2).to.have.all.keys(['type', 'execute']);
expect(pl2.type).toBe(PluginType.Control);

View File

@@ -1,11 +1,13 @@
//@ts-nocheck
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PluginType, SernOptionsData, controller } from '../../src/index';
import { partitionPlugins, treeSearch } from '../../src/core/functions';
import { faker } from '@faker-js/faker';
import { ApplicationCommandOptionType, AutocompleteInteraction } from 'discord.js';
vi.mock('discord.js', () => {
const Collection = Map;
vi.mock('discord.js', async (importOriginal) => {
const mod = await importOriginal()
const ModalSubmitInteraction = class {
customId;
type = 5;
@@ -36,35 +38,11 @@ vi.mock('discord.js', () => {
};
return {
Collection,
ComponentType: {
Button: 2,
},
InteractionType: {
Ping: 1,
ApplicationCommand: 2,
MessageComponent: 3,
ApplicationCommandAutocomplete: 4,
ModalSubmit: 5,
},
ApplicationCommandOptionType: {
Subcommand: 1,
SubcommandGroup: 2,
String: 3,
Integer: 4,
Boolean: 5,
User: 6,
Channel: 7,
Role: 8,
Mentionable: 9,
Number: 10,
Attachment: 11,
},
ApplicationCommandType: {
ChatInput: 1,
User: 2,
Message: 3,
},
Collection: mod.Collection,
ComponentType: mod.ComponentType,
InteractionType: mod.InteractionType,
ApplicationCommandOptionType: mod.ApplicationCommandOptionType,
ApplicationCommandType: mod.ApplicationCommandType,
ModalSubmitInteraction,
ButtonInteraction,
AutocompleteInteraction,

135
test/core/id.test.ts Normal file
View File

@@ -0,0 +1,135 @@
//@ts-nocheck
import { expect, test, vi } from 'vitest'
import { CommandType } from '../../src/core/structures/enums';
import * as Id from '../../src/core/id'
import { ButtonInteraction, ModalSubmitInteraction } from 'discord.js';
vi.mock('discord.js', async (importOriginal) => {
const mod = await importOriginal()
const ModalSubmitInteraction = class {
customId;
type = 5;
isModalSubmit = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const ButtonInteraction = class {
customId;
type = 3;
componentType = 2;
isButton = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const AutocompleteInteraction = class {
type = 4;
option: string;
constructor(s: string) {
this.option = s;
}
options = {
getFocused: vi.fn(),
getSubcommand: vi.fn(),
};
};
return {
Collection: mod.Collection,
ComponentType: mod.ComponentType,
InteractionType: mod.InteractionType,
ApplicationCommandOptionType: mod.ApplicationCommandOptionType,
ApplicationCommandType: mod.ApplicationCommandType,
ModalSubmitInteraction,
ButtonInteraction,
AutocompleteInteraction,
};
});
test('id -> Text', () => {
expect(Id.create("ping", CommandType.Text)).toBe("ping_T")
})
test('id -> Both', () => {
expect(Id.create("ping", CommandType.Both)).toBe("ping_B")
})
test('id -> CtxMsg', () => {
expect(Id.create("ping", CommandType.CtxMsg)).toBe("ping_A3")
})
test('id -> CtxUsr', () => {
expect(Id.create("ping", CommandType.CtxUser)).toBe("ping_A2")
})
test('id -> Modal', () => {
expect(Id.create("my-modal", CommandType.Modal)).toBe("my-modal_M");
})
test('id -> Button', () => {
expect(Id.create("my-button", CommandType.Button)).toBe("my-button_C2");
})
test('id -> Slash', () => {
expect(Id.create("myslash", CommandType.Slash)).toBe("myslash_A1");
})
test('id -> StringSelect', () => {
expect(Id.create("mystringselect", CommandType.StringSelect)).toBe("mystringselect_C3");
})
test('id -> UserSelect', () => {
expect(Id.create("myuserselect", CommandType.UserSelect)).toBe("myuserselect_C5");
})
test('id -> RoleSelect', () => {
expect(Id.create("myroleselect", CommandType.RoleSelect)).toBe("myroleselect_C6");
})
test('id -> MentionSelect', () => {
expect(Id.create("mymentionselect", CommandType.MentionableSelect)).toBe("mymentionselect_C7");
})
test('id -> ChannelSelect', () => {
const modal = Id.create("mychannelselect", CommandType.ChannelSelect)
expect(modal).toBe("mychannelselect_C8");
})
test('id reconstruct button', () => {
const idload = Id.reconstruct(new ButtonInteraction("btn"))
expect(idload[0].id).toBe("btn_C2")
})
test('id reconstruct button with params', () => {
const idload = Id.reconstruct(new ButtonInteraction("btn/asdf"))
expect(idload[0].id).toBe("btn_C2")
expect(idload[0].params).toBe("asdf")
})
test('id reconstruct modal with params', () => {
const idload = Id.reconstruct(new ModalSubmitInteraction("btn/asdf"))
expect(idload[0].id).toBe("btn_M")
expect(idload[0].params).toBe("asdf")
})
test('id reconstruct modal', () => {
const idload = Id.reconstruct(new ModalSubmitInteraction("btn"))
expect(idload[0].id).toBe("btn_M")
expect(idload[0].params).toBe(undefined)
})
test('id reconstruct button with empty params', () => {
const idload = Id.reconstruct(new ButtonInteraction("btn/"))
expect(idload[0].id).toBe("btn_C2")
expect(idload[0].params).toBe("")
})
test('id reconstruct with multiple slashes', () => {
const idload = Id.reconstruct(new ButtonInteraction("btn//"))
expect(idload[0].id).toBe("btn_C2")
expect(idload[0].params).toBe("/")
})
test('id reconstruct button', () => {
const idload = Id.reconstruct(new ButtonInteraction("btn"))
expect(idload[0].id).toBe("btn_C2")
expect(idload[0].params).toBe(undefined)
})

View File

@@ -1,52 +0,0 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { CoreContainer } from '../../src/core/ioc/container';
import { CoreDependencies } from '../../src/core/ioc';
import { EventEmitter } from 'events';
import { DefaultLogging, Init, Logging } from '../../src/core';
describe('ioc container', () => {
let container: CoreContainer<{}>;
let initDependency: Logging & Init;
beforeEach(() => {
initDependency = {
init: vi.fn(),
error(): void {},
warning(): void {},
info(): void {},
debug(): void {},
};
container = new CoreContainer();
});
it('should be ready after calling container.ready()', () => {
container.ready();
expect(container.isReady()).toBe(true);
});
it('should container all core dependencies', async () => {
const keys = [
'@sern/modules',
'@sern/emitter',
'@sern/logger',
'@sern/errors',
] satisfies (keyof CoreDependencies)[];
container.add({
'@sern/logger': () => new DefaultLogging(),
'@sern/client': () => new EventEmitter(),
});
for (const k of keys) {
//@ts-expect-error typings for iti are strict
expect(() => container.get(k)).not.toThrow();
}
});
it('should init modules', () => {
container.upsert({ '@sern/logger': initDependency });
container.ready();
expect(initDependency.init).to.toHaveBeenCalledOnce();
});
it('should not lazy module', () => {
container.upsert({ '@sern/logger': () => initDependency });
container.ready();
expect(initDependency.init).toHaveBeenCalledTimes(0);
});
});

View File

@@ -0,0 +1,64 @@
import { describe, it, expect } from 'vitest'
import path from 'node:path'
import * as Files from '../../src/core/module-loading'
import { Module } from '../../src/types/core-modules'
import { AssertionError } from 'node:assert'
//TODO: mock fs?
describe('module-loading', () => {
it('should get the filename of the commandmodule (linux, esm)', () => {
const fname = "///home/pooba/Projects/sern/halibu/dist/commands/ping.js"
const callsiteinfo = Files.parseCallsite(fname)
expect(callsiteinfo.name).toBe("ping")
})
it('should get filename of commandmodule (linux, cjs)', () => {
const fname = "file:///home/pooba/Projects/sern/halibu/dist/commands/ping.js"
const callsiteinfo = Files.parseCallsite(fname)
expect(callsiteinfo.name).toBe("ping")
})
it('should get the filename of the commandmodule (windows, cjs)', () => {
//this test case is impossible on linux.
if(process.platform == 'win32') {
const fname = "C:\\pooba\\Projects\\sern\\halibu\\dist\\commands\\ping.js"
const callsiteinfo = Files.parseCallsite(fname)
expect(callsiteinfo.name).toEqual("ping");
}
})
it('should get filename of commandmodule (windows, esm)', () => {
//this test case is impossible on linux.
if(process.platform == 'win32') {
const fname = "file:///C:\\pooba\\Projects\\sern\\halibu\\dist\\commands\\ping.js"
const callsiteinfo = Files.parseCallsite(fname)
expect(callsiteinfo.name).toEqual("ping");
}
})
it('should import a commandModule properly', async () => {
const { module } = await Files.importModule<Module>(path.resolve("test", 'mockules', "module.ts"));
expect(module.name).toBe('module')
})
it('should throw when failed commandModule import', async () => {
try {
await Files.importModule(path.resolve('test', 'mockules', 'failed.ts'))
} catch(e) {
expect(e instanceof AssertionError)
}
})
it('should throw when failed commandModule import', async () => {
try {
await Files.importModule(path.resolve('test', 'mockules', 'failed.ts'))
} catch(e) {
expect(e instanceof AssertionError)
}
})
it('reads all modules in mockules', async () => {
const ps = [] as string[]
for await (const fpath of Files.readRecursive(path.resolve('test', 'mockules'))) {
ps.push(fpath)
}
expect(ps.length === 4)
})
})

View File

@@ -0,0 +1,57 @@
import { describe, expect, it, vi } from 'vitest';
import { Presence } from '../../src';
// Example test suite for the module function
describe('module function', () => {
it('should return a valid configuration', () => {
const config = Presence.module({
inject: ['dependency1', 'dependency2'],
execute: vi.fn(),
});
expect(config).toBeDefined();
expect(config.inject).toEqual(['dependency1', 'dependency2']);
expect(typeof config.execute).toBe('function');
});
});
describe('of function', () => {
it('should return a valid presence configuration without repeat and onRepeat', () => {
const presenceConfig = Presence.of({
status: 'online',
afk: false,
activities: [{ name: 'Test Activity' }],
shardId: [1, 2, 3],
}).once();
expect(presenceConfig).toBeDefined();
//@ts-ignore Maybe fix?
expect(presenceConfig.repeat).toBeUndefined();
//@ts-ignore Maybe fix?
expect(presenceConfig.onRepeat).toBeUndefined();
expect(presenceConfig).toMatchObject({
status: 'online',
afk: false,
activities: [{ name: 'Test Activity' }],
shardId: [1, 2, 3],
});
});
it('should return a valid presence configuration with repeat and onRepeat', () => {
const onRepeatCallback = vi.fn();
const presenceConfig = Presence.of({
status: 'idle',
activities: [{ name: 'Another Test Activity' }],
}).repeated(onRepeatCallback, 5000);
expect(presenceConfig).toBeDefined();
expect(presenceConfig.repeat).toBe(5000);
expect(presenceConfig.onRepeat).toBe(onRepeatCallback);
expect(presenceConfig).toMatchObject({
status: 'idle',
activities: [{ name: 'Another Test Activity' }],
});
});
})

View File

@@ -1,77 +0,0 @@
import { SpyInstance, afterAll, beforeEach, describe, expect, it, vi } from 'vitest';
import { CoreContainer } from '../../src/core/ioc/container';
import { DefaultLogging } from '../../src/core';
import { faker } from '@faker-js/faker';
import { commandModule } from '../../src';
import * as Id from '../../src/core/id';
import { CommandMeta } from '../../src/types/core-modules';
describe('services', () => {
//@ts-ignore
let container: CoreContainer<Dependencies>;
let consoleMock: SpyInstance;
beforeEach(() => {
container = new CoreContainer();
container.add({ '@sern/logger': () => new DefaultLogging() });
container.ready();
consoleMock = vi.spyOn(container.get('@sern/logger'), 'error').mockImplementation(() => {});
});
afterAll(() => {
consoleMock.mockReset();
});
it('module-store.ts', async () => {
function createRandomCommandModules() {
return commandModule({
type: faker.number.int({ min: 1 << 0, max: 1 << 10 }),
description: faker.string.alpha(),
name: faker.string.alpha(),
execute: () => {},
});
}
const modules = faker.helpers.multiple(createRandomCommandModules, {
count: 40,
});
const paths = faker.helpers
.multiple(faker.system.directoryPath, { count: 40 })
.map((path, i) => `${path}/${modules[i]}.js`);
const metadata: CommandMeta[] = modules.map((cm, i) => ({
id: Id.create(cm.name, cm.type),
isClass: false,
fullPath: `${paths[i]}/${cm.name}.js`,
}));
const moduleManager = container.get('@sern/modules');
let i = 0;
for (const m of modules) {
moduleManager.set(Id.create(m.name, m.type), paths[i]);
moduleManager.setMetadata(m, metadata[i]);
i++;
}
for (const m of modules) {
expect(moduleManager.getMetadata(m), 'module references do not exist').toBeDefined();
}
});
//todo add more
it('error-handling', () => {
const errorHandler = container.get('@sern/errors');
const lifetime = errorHandler.keepAlive;
for (let i = 0; i < lifetime; i++) {
if (i == lifetime - 1) {
expect(() => errorHandler.updateAlive(new Error('poo'))).toThrowError();
} else {
expect(() => errorHandler.updateAlive(new Error('poo'))).not.toThrowError();
}
}
});
//todo add more, spy on every instance?
it('logger', () => {
container.get('@sern/logger').error({ message: 'error' });
expect(consoleMock).toHaveBeenCalledOnce();
expect(consoleMock).toHaveBeenLastCalledWith({ message: 'error' });
});
});

151
test/handlers.test.ts Normal file
View File

@@ -0,0 +1,151 @@
//@ts-nocheck
import { beforeEach, describe, expect, vi, it, test } from 'vitest';
import { callInitPlugins, eventDispatcher } from '../src/handlers/event-utils';
import { Client, ChatInputCommandInteraction } from 'discord.js'
import { faker } from '@faker-js/faker';
import { Module } from '../src/types/core-modules';
import { Processed } from '../src/types/core-modules';
import { EventEmitter } from 'events';
import { EventType } from '../src/core/structures/enums';
import { CommandControlPlugin, CommandInitPlugin, CommandType, controller } from '../src';
vi.mock('discord.js', async (importOriginal) => {
const mod = await importOriginal()
const ModalSubmitInteraction = class {
customId;
type = 5;
isModalSubmit = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const ButtonInteraction = class {
customId;
type = 3;
componentType = 2;
isButton = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const AutocompleteInteraction = class {
type = 4;
option: string;
constructor(s: string) {
this.option = s;
}
options = {
getFocused: vi.fn(),
getSubcommand: vi.fn(),
};
};
return {
Client : vi.fn(),
Collection: mod.Collection,
ComponentType: mod.ComponentType,
InteractionType: mod.InteractionType,
ApplicationCommandOptionType: mod.ApplicationCommandOptionType,
ApplicationCommandType: mod.ApplicationCommandType,
ModalSubmitInteraction,
ButtonInteraction,
AutocompleteInteraction,
ChatInputCommandInteraction: vi.fn()
};
});
function createRandomPlugin (s: 'go', mut?: Partial<Module>) {
return CommandInitPlugin(({ module }) => {
if(mut) {
Object.entries(mut).forEach(([k, v]) => {
module[k] = v
})
}
return s == 'go'
? controller.next()
: controller.stop()
})
}
function createRandomModule(plugins: any[]): Processed<Module> {
return {
type: EventType.Discord,
meta: { id:"", absPath: "" },
description: faker.string.alpha(),
plugins,
name: "cheese",
onEvent: [],
execute: vi.fn(),
};
}
function mockDeps() {
return {
'@sern/client': new Client(),
'@sern/emitter': new EventEmitter()
}
}
describe('eventDispatcher standard', () => {
let m: Processed<Module>;
let ee: EventEmitter;
beforeEach(() => {
ee = new EventEmitter();
m = createRandomModule();
});
it('should throw', () => {
expect(() => eventDispatcher(mockDeps(), m, 'not event emitter')).toThrowError();
});
it("Shouldn't throw", () => {
expect(() => eventDispatcher(mockDeps(), m, ee)).not.toThrowError();
});
});
test ('call init plugins', async () => {
const deps = mockDeps()
const plugins = createRandomPlugin('go', { name: "abc" })
const mod = createRandomModule([plugins])
const s = await callInitPlugins(mod, deps, false)
expect("abc").equal(s.name)
})
test('init plugins replace array', async () => {
const deps = mockDeps()
const plugins = createRandomPlugin('go', { opts: [] })
const plugins2 = createRandomPlugin('go', { opts: ['a'] })
const mod = createRandomModule([plugins, plugins2])
const s = await callInitPlugins(mod, deps, false)
expect(['a']).deep.equal(s.opts)
})
test('call control plugin ', async () => {
const plugin = CommandControlPlugin<CommandType.Slash>((ctx,sdt) => {
return controller.next();
});
const res = await plugin.execute(new ChatInputCommandInteraction(), {})
expect(res.isOk()).toBe(true)
})
test('form sdt', async () => {
const expectedObject = {
"plugin/abc": faker.person.jobArea(),
"plugin2/abc": faker.git.branch(),
"plugin3/cheese": faker.person.jobArea()
}
const plugin = CommandControlPlugin<CommandType.Slash>((ctx,sdt) => {
return controller.next({ "plugin/abc": expectedObject['plugin/abc'] });
});
const plugin2 = CommandControlPlugin<CommandType.Slash>((ctx,sdt) => {
return controller.next({ "plugin2/abc": expectedObject['plugin2/abc'] });
});
const plugin3 = CommandControlPlugin<CommandType.Slash>((ctx,sdt) => {
return controller.next({ "plugin3/cheese": expectedObject['plugin3/cheese'] });
});
})

View File

@@ -1,45 +0,0 @@
import { beforeEach, describe, expect, vi, it } from 'vitest';
import { createResultResolver, eventDispatcher } from '../../src/handlers/_internal';
import { faker } from '@faker-js/faker';
import { Module } from '../../src/core/types/modules';
import { Processed } from '../../src/handlers/types';
import { CommandType } from '../../src/core';
import { EventEmitter } from 'events';
function createRandomModule(): Processed<Module> {
return {
type: faker.number.int({
min: CommandType.Text,
max: CommandType.ChannelSelect,
}),
description: faker.string.alpha(),
name: faker.string.alpha(),
onEvent: [],
plugins: [],
execute: vi.fn(),
};
}
describe('eventDispatcher standard', () => {
let m: Processed<Module>;
let ee: EventEmitter;
beforeEach(() => {
ee = new EventEmitter();
m = createRandomModule();
});
it('should throw', () => {
expect(() => eventDispatcher(m, 'not event emitter')).toThrowError();
});
it("Shouldn't throw", () => {
expect(() => eventDispatcher(m, ee)).not.toThrowError();
});
it('Should be called once', () => {
const s = eventDispatcher(m, ee);
s.subscribe();
ee.emit(m.name, faker.string.alpha());
expect(m.execute).toHaveBeenCalledOnce();
});
});

View File

@@ -1,72 +0,0 @@
import { describe, expect, it, vi } from 'vitest';
import * as Id from '../../src/core/id';
import { faker } from '@faker-js/faker';
import { CommandModule, CommandType, commandModule } from '../../src';
import { CommandTypeDiscordApi } from '../../src/core/id';
function createRandomCommandModules() {
const randomCommandType = [
CommandType.Text,
CommandType.Both,
CommandType.CtxMsg,
CommandType.CtxUser,
CommandType.Modal,
CommandType.ChannelSelect,
CommandType.RoleSelect,
CommandType.UserSelect,
CommandType.StringSelect,
CommandType.Button,
];
return commandModule({
type: randomCommandType[Math.floor(Math.random() * randomCommandType.length)],
description: faker.string.alpha(),
name: faker.string.alpha(),
execute: () => {},
});
}
function createMetadata(c: CommandModule) {
return {
fullPath: faker.system.filePath(),
id: Id.create(c.name, c.type),
isClass: Boolean(Math.floor(Math.random())),
};
}
const appBitField = 0b000000001111;
describe('id resolution', () => {
it('should resolve application commands correctly', () => {
const modules = faker.helpers.multiple(createRandomCommandModules, {
count: 20,
});
const metadata = modules.map(createMetadata);
metadata.forEach((meta, idx) => {
const associatedModule = modules[idx];
const am = (appBitField & associatedModule.type) !== 0 ? 'A' : 'C';
let uid = 0;
if (
associatedModule.type === CommandType.Both ||
associatedModule.type === CommandType.Modal
) {
uid = 1;
} else {
uid = CommandTypeDiscordApi[Math.log2(associatedModule.type)];
}
expect(meta.id).toBe(associatedModule.name + '_' + am + uid);
});
});
it('maps commands type to discord components or application commands', () => {
expect(CommandTypeDiscordApi[Math.log2(CommandType.Text)]).toBe(1);
expect(CommandTypeDiscordApi[1]).toBe(1);
expect(CommandTypeDiscordApi[Math.log2(CommandType.CtxUser)]).toBe(2);
expect(CommandTypeDiscordApi[Math.log2(CommandType.CtxMsg)]).toBe(3);
expect(CommandTypeDiscordApi[Math.log2(CommandType.Button)]).toBe(2);
expect(CommandTypeDiscordApi[Math.log2(CommandType.StringSelect)]).toBe(3);
expect(CommandTypeDiscordApi[Math.log2(CommandType.UserSelect)]).toBe(5);
expect(CommandTypeDiscordApi[Math.log2(CommandType.RoleSelect)]).toBe(6);
expect(CommandTypeDiscordApi[Math.log2(CommandType.MentionableSelect)]).toBe(7);
expect(CommandTypeDiscordApi[Math.log2(CommandType.ChannelSelect)]).toBe(8);
expect(CommandTypeDiscordApi[6]).toBe(1);
});
});

0
test/mockules/!ignd.ts Normal file
View File

View File

0
test/mockules/failed.ts Normal file
View File

6
test/mockules/module.ts Normal file
View File

@@ -0,0 +1,6 @@
import { CommandType, commandModule } from '../../src/'
export default commandModule({
type: CommandType.Both,
description: "",
execute: (Ctx, args) => {}
})

6
test/mockules/ug/pass.ts Normal file
View File

@@ -0,0 +1,6 @@
import { CommandType, commandModule } from '../../../src/'
export default commandModule({
type: CommandType.Both,
description: "",
execute: (Ctx, args) => {}
})

View File

@@ -3,18 +3,18 @@
"rootDir": "src",
"strict": true,
"esModuleInterop": true,
"noImplicitAny": true,
"experimentalDecorators": true,
"strictNullChecks": true,
"moduleResolution": "node",
"moduleResolution": "node16",
"skipLibCheck": true,
"declaration": true,
"preserveSymlinks": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true,
"isolatedModules": true,
"module": "esnext",
"target": "esnext"
"outDir": "dist",
"module": "node16",
"target": "esnext",
"sourceMap": true
},
"exclude": ["node_modules", "dist"],
"include": ["./src", "./src/**/*.d.ts"]

View File

@@ -1,49 +0,0 @@
import { defineConfig } from 'tsup';
const shared = {
entry: ['src/index.ts'],
external: ['discord.js', 'iti'],
platform: 'node',
clean: true,
sourcemap: false,
treeshake: {
moduleSideEffects: false,
correctVarValueBeforeDeclaration: true, //need this to treeshake esm discord.js empty import
annotations: true,
},
};
export default defineConfig([
{
format: ['esm', 'cjs'],
target: 'node18',
tsconfig: './tsconfig.json',
outDir: './dist',
splitting: true,
dts: true,
...shared,
},
// {
// format: 'cjs',
// esbuildPlugins: [ifdefPlugin({ variables: { MODE: 'cjs' }, verbose: true })],
// splitting: false,
// target: 'node18',
// tsconfig: './tsconfig-cjs.json',
// outDir: './dist/cjs',
// outExtension() {
// return {
// js: '.cjs',
// };
// },
// async onSuccess() {
// console.log('writing json commonjs');
// await writeFile('./dist/cjs/package.json', JSON.stringify({ type: 'commonjs' }));
// },
// ...shared,
// },
// {
// dts: {
// only: true,
// },
// entry: ['src/index.ts'],
// outDir: 'dist',
// },
]);

3091
yarn.lock

File diff suppressed because it is too large Load Diff