Compare commits

...

147 Commits

Author SHA1 Message Date
Jacob Nguyen
adb3d4f402 3.0.0-rc1 2023-05-23 14:50:20 -05:00
Jacob Nguyen
4d1e6f2a71 refactor: npx knip 2023-05-23 11:12:54 -05:00
Jacob Nguyen
63e0daac16 fix: typings for options- have access to all properties now 2023-05-22 22:50:05 -05:00
Jacob Nguyen
49fb1453a5 revert removal of crash method and mark deprecated 2023-05-19 20:48:38 -05:00
Jacob Nguyen
a6db21c720 docs: add purpose of d.ts file 2023-05-19 20:33:11 -05:00
Jacob Nguyen
fc6206a205 chore: make changes in codebase after error contract change 2023-05-19 20:30:32 -05:00
Jacob Nguyen
d7195c45b3 feat: change error handling contract 2023-05-19 20:15:25 -05:00
Jacob Nguyen
698d468acb test: add more tests 2023-05-19 19:43:05 -05:00
Jacob Nguyen
5d842fd5e7 test: add tests 2023-05-19 02:08:08 -05:00
Jacob Nguyen
b6ef27df88 refactor: add better typings for sern event modules 2023-05-18 22:52:37 -05:00
Jacob Nguyen
ea78a98f69 refactor: remove cast 2023-05-18 21:36:41 -05:00
Jacob Nguyen
08e358b32d feat: add new sern emitter event 2023-05-18 21:35:15 -05:00
Jacob Nguyen
47f58a9efa revert: internal name 2023-05-18 20:03:53 -05:00
Jacob Nguyen
b8619df838 feat: globalize dependencies type 2023-05-18 18:54:03 -05:00
Jacob Nguyen
e4a9c8d551 fix class based module loading 2023-05-18 13:06:40 -05:00
Jacob Nguyen
59a13765a5 style: pretty 2023-05-18 11:57:28 -05:00
Jacob Nguyen
f2b53dac32 fix: plugins for class modules and module loader 2023-05-18 11:49:14 -05:00
Jacob Nguyen
8f39fc0896 refactor: a lot 2023-05-15 17:35:38 -05:00
Jacob Nguyen
423b0d2aa7 revert: re export command executable and event executable 2023-05-15 17:22:25 -05:00
Jacob Nguyen
b2ab99b7cc refactor: move metadata outside of module declarations 2023-05-15 17:10:55 -05:00
Jacob Nguyen
b06e457caa fix: directories ignoring incorrectly 2023-05-13 01:04:50 -05:00
Jacob Nguyen
6fd9f8f032 feat: dev and prod mode 2023-05-13 00:52:46 -05:00
Jacob Nguyen
3ed6d84727 style: format & lint 2023-05-11 09:50:51 -05:00
Jacob Nguyen
95ec4d0e3c chore: update container and init hook progress 2023-05-11 01:33:49 -05:00
Jacob Nguyen
5e878cc97b chore: progress of globalizing dependencies type 2023-05-10 21:57:55 -05:00
Jacob Nguyen
57dfa09a73 add separate id for id processing 2023-05-10 21:41:18 -05:00
Jacob Nguyen
0256a38187 revert: readd module store and add contract 2023-05-10 21:40:40 -05:00
Jacob Nguyen
e6f3a55d73 build: speed up build 2023-05-10 21:35:54 -05:00
Jacob Nguyen
7ff8917a26 fix: unaliased modules would throw error 2023-05-10 15:09:26 -05:00
Jacob Nguyen
8a537d670b feat: types organization and cleaning up code base 2023-05-09 22:49:29 -05:00
Jacob Nguyen
cd1568ff69 refactor: rename, format, move things 2023-05-08 11:36:25 -05:00
Jacob Nguyen
9718360530 revert: readd discordEvent 2023-05-08 00:29:48 -05:00
Jacob Nguyen
6bd7573b88 revert: multiple entry points 2023-05-08 00:29:10 -05:00
Jacob Nguyen
993c41f37e chore: update name of function to reflect use 2023-05-08 00:27:36 -05:00
Jacob Nguyen
17423b683e refactor: cleaning up code and renaming variables 2023-05-08 00:27:01 -05:00
Jacob Nguyen
8d507d0520 chore: move context into structures directory 2023-05-07 23:41:23 -05:00
Jacob Nguyen
8dacfafbac chore: cleanup dispatchers subdirectory for single file 2023-05-07 23:37:23 -05:00
Jacob Nguyen
98de65965e chore: delete plugins dir 2023-05-07 00:46:50 -05:00
Jacob Nguyen
e411f81ab9 chore: move and clean 2023-05-07 00:46:22 -05:00
Jacob Nguyen
f6e41cc9b6 chore: move typings 2023-05-07 00:44:29 -05:00
Jacob Nguyen
9f5d2faa61 chore: fix InitArgs missing 2023-05-07 00:43:20 -05:00
Jacob Nguyen
815cbf19eb feat: SernEmitter now captures promise rejections 2023-05-06 23:23:24 -05:00
Jacob Nguyen
4ff2f39fcf revert: remove cloudflare typings 2023-05-06 22:13:59 -05:00
Jacob Nguyen
87585dcc69 refactor: change all files to camelcase and refactor 2023-05-06 22:13:09 -05:00
Jacob Nguyen
8c28e00206 chore: change all file names to camel case 2023-05-06 22:12:37 -05:00
Jacob Nguyen
bf6b99ff03 docs: specifics 2023-05-06 03:18:09 -05:00
Jacob Nguyen
9f213e9e8c refactor: fold switch case 2023-05-06 03:17:43 -05:00
Jacob Nguyen
4794c2655d revert: remove AnyDependencies type 2023-05-06 03:13:02 -05:00
Jacob Nguyen
43181bf916 style: pretty 2023-05-06 02:04:00 -05:00
Jacob Nguyen
8e9b3bbe04 chore: remove bad imports 2023-05-06 02:01:27 -05:00
Jacob Nguyen
098a440669 feat: add getPublishableCommands to ModuleManager 2023-05-06 01:49:42 -05:00
Jacob Nguyen
1e1398fade refactor: remove unneeded signatures and fix imports 2023-05-06 01:16:53 -05:00
Jacob Nguyen
1fea6fa136 refactor: add createGenericHandler 2023-05-06 01:01:54 -05:00
Jacob Nguyen
ff478123a8 chore: revert multi and clean up code 2023-05-05 23:31:51 -05:00
Jacob Nguyen
29499457bb chore: revert multi and mov sernEmitter 2023-05-05 23:14:54 -05:00
Jacob Nguyen
084b5adb5c chore: revert multiplatform 2023-05-05 23:10:10 -05:00
Jacob Nguyen
397857208e chore: modularize and split typings 2023-05-05 23:01:59 -05:00
Jacob Nguyen
ed532af7f7 revert: multiplatform 2023-05-05 22:52:24 -05:00
Jacob Nguyen
6628fa7074 revert: multiplatform 2023-05-05 22:51:59 -05:00
Jacob Nguyen
3d0cf62b9a chore: add tweetnacl 2023-05-05 10:12:39 -05:00
Jacob Nguyen
8e6061b4f2 chore: update Proccessed typing to ./core 2023-05-05 10:12:16 -05:00
Jacob Nguyen
e644b184f2 chore: add old context here until i figure out what to do 2023-05-04 18:55:55 -05:00
Jacob Nguyen
dbfd8cfe40 chore: add agnostic predicates 2023-05-04 18:47:28 -05:00
Jacob Nguyen
b0f6093ac6 chore: remove leaky import 2023-05-04 18:47:13 -05:00
Jacob Nguyen
1288a6b5cc chore: refactor and add multiplatform typings 2023-05-04 18:46:52 -05:00
Jacob Nguyen
7f53521ef4 chore: refactor and move things 2023-05-04 18:46:23 -05:00
Jacob Nguyen
faa0b4882e feat!: new module resolution algorithm 2023-05-04 18:45:54 -05:00
Jacob Nguyen
e2fd44516a refactor: internalize operators 2023-05-04 18:42:02 -05:00
Jacob Nguyen
4125508cd3 chore: redundant directory 2023-05-03 21:09:04 -05:00
Jacob Nguyen
2ecf3e5a5c chore: rename, wip refactoring 2023-05-03 21:08:20 -05:00
Jacob Nguyen
d53b315157 chore: remove redundant directories for single files 2023-05-03 21:05:19 -05:00
Jacob Nguyen
b7e99a85a6 chore: serverless work 2023-05-03 21:04:58 -05:00
Jacob Nguyen
aacf47efab chore: move commands into seperate file 2023-05-03 21:04:10 -05:00
Jacob Nguyen
cae1f648f0 refactor: move and update things 2023-05-03 21:02:51 -05:00
Jacob Nguyen
b7a13d6558 chore: remove redundant directories for single files 2023-05-03 15:08:07 -05:00
Jacob Nguyen
de654532e8 chore: remove redundant directories for single files 2023-05-03 13:01:14 -05:00
Jacob Nguyen
a5e8743afd chore: remove utils folder in favor of single file 2023-05-03 13:00:11 -05:00
Jacob Nguyen
3a8098e523 move eslint and prettier configs to json 2023-05-03 11:53:58 -05:00
Jacob Nguyen
21a20523b8 chore(core): add DefaultWrapper as sern classic 2023-05-02 10:28:58 -05:00
Jacob Nguyen
52b186cc44 chore(core): update imports and operators 2023-05-02 10:27:53 -05:00
Jacob Nguyen
a17be67b48 chore: move prettier into package.json 2023-05-01 20:51:54 -05:00
Jacob Nguyen
6a65dac272 chore: remove extra file 2023-04-29 18:05:37 -05:00
Jacob Nguyen
7f59f81b35 feat: make Context platform specific, CoreContext as Core 2023-04-27 13:23:16 -05:00
Jacob Nguyen
e6f9207a47 chore: move command args matrix as binding 2023-04-27 13:22:15 -05:00
Jacob Nguyen
fa4e6c7fbd chore: fix typings 2023-04-27 13:21:25 -05:00
Jacob Nguyen
d41cc9ea2d move strategy to index.ts and add adapters 2023-04-27 13:20:11 -05:00
Jacob Nguyen
97ad338830 lift requiredDependencyKeys out of makeFetcher 2023-04-26 21:25:53 -05:00
Jacob Nguyen
dcdf120ba7 chore: update typings 2023-04-26 11:58:22 -05:00
Jacob Nguyen
1fa7eef336 chore: remove dependence on discord.js Awaitable type 2023-04-16 14:17:56 -05:00
Jacob Nguyen
37ddef170f chore: remove deprecated support for plugins 2023-04-16 14:13:17 -05:00
Jacob Nguyen
195c155769 docs: add documentation to internal function 2023-04-16 13:33:35 -05:00
Jacob Nguyen
458a0b978f chore: remove deprecated symbols 2023-04-16 13:30:20 -05:00
Jacob Nguyen
bc4802f261 chore:remove const function 2023-04-16 13:27:17 -05:00
Jacob Nguyen
8e2ba871c4 chore:update paths 2023-04-15 16:48:04 -05:00
Jacob Nguyen
83f6568d84 add deprecation warning 2023-04-15 12:12:02 -05:00
Jacob Nguyen
f02bbbf54e add wrapper platform field 2023-04-15 12:12:02 -05:00
jacoobes
89bb50794b chore: fix paths 2023-04-14 13:05:27 -05:00
jacoobes
1a4e046f50 chore: move operators into core 2023-04-14 12:59:13 -05:00
jacoobes
09e57c73ac moving and fixing imports 2023-04-14 12:45:41 -05:00
jacoobes
457cb25cb2 refactor: remove dependence on discord.js for module stoore 2023-04-14 12:17:11 -05:00
Jacob Nguyen
b40f8905ea remove id from lifted Context 2023-04-12 17:06:54 -05:00
Jacob Nguyen
8bdb6d8216 work on strategy and lifted Context 2023-04-12 17:03:35 -05:00
Jacob Nguyen
58b3d85da8 refactor: move things to core, imports not fixed yet 2023-04-12 13:08:45 -05:00
xxDeveloper
21febd2c90 chore: Update SECURITY.md (#276)
Semantic security file

Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-04-11 22:46:02 -05:00
xxDeveloper
11daebb30a chore: Update LICENSE (#275)
We're in 2023
We're sern, not Sern

Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-04-11 22:45:45 -05:00
github-actions[bot]
b817f98c10 style: pretty please (#277)
Co-authored-by: EvolutionX-10 <EvolutionX-10@users.noreply.github.com>
2023-04-11 22:45:30 -05:00
Evo
563f583318 chore: switch to yarn (#273)
* chore: switch to yarn

* chore: pointless limitation

permalink: http://whatthecommit.com/468a491808723d12de48b079d9092b44

* chore: i can't believe it took so long to fix this.

permalink: http://whatthecommit.com/b298fe6d3375ab953abfdb0f1f737826
2023-04-11 12:45:16 -05:00
EvolutionX
e4c7bfe686 chore: ok work pls 2023-04-11 22:32:31 +05:30
EvolutionX
69fa4908c3 chore: refresh lockfile 2023-04-11 22:09:32 +05:30
renovate[bot]
4fa28d605f chore(deps): lock file maintenance (#245)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-11 10:32:46 -05:00
Jacob Nguyen
079b554f8b Update continuous-integration.yml 2023-04-11 10:31:15 -05:00
Jacob Nguyen
dec56335b9 Update codeql-analysis.yml 2023-04-11 10:30:41 -05:00
Jacob Nguyen
50be972d4f Update continuous-integration.yml 2023-04-11 10:29:06 -05:00
Jacob Nguyen
507d183970 Update codeql-analysis.yml 2023-04-11 10:28:29 -05:00
renovate[bot]
90edd4f91e chore(deps): update dependency eslint to v8.38.0 (#180)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-11 10:24:32 -05:00
renovate[bot]
5f11142599 chore(deps): update dependency @typescript-eslint/parser to v5.58.0 (#250)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-04-11 10:22:27 -05:00
renovate[bot]
7a635f9978 chore(deps): update actions/checkout digest to 8f4b7f8 (#261)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-04-11 10:21:59 -05:00
renovate[bot]
a17aeac558 chore(deps): update pnpm to v7.32.0 (#262)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-04-11 10:21:39 -05:00
renovate[bot]
af6ebed348 chore(deps): update dependency @typescript-eslint/eslint-plugin to v5.58.0 (#249)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-11 10:18:46 -05:00
renovate[bot]
2f96b7634d chore(deps): update dependency prettier to v2.8.7 (#263)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-04-11 10:18:17 -05:00
EvolutionX
97741faa69 chore: refresh lockfile 2023-04-11 16:02:13 +05:30
Jacob Nguyen
94070d99e8 refactor/decoupling (#265)
* fix npm script for workflows

* filter lazy modules

* lift inline function for readability

* perf: use one instance of operator instead of creating instances

* chore: move fmt closer to call site

* refactor: inline function lifting and readability

* add import payload type

* refactor: remove redundant pipe for single function operators

* refactor: clearer naming for resultResolver

* refactor: no unused variable warning for updateAlive

* style: pretty

* refactor: remove redundant getter

* style: pretty

* fix: typescript needs explicit definition for defineAllFields

* add LazyPaths map

* chore: update tsup and typescript

* chore: revert lazy module work and work on decoupling core

* fix npm script for workflows

* chore: fix typings

* refactor: inline function `defineAllFields`

* docs: add @since annotation

* style: prettier

* docs: add since annotations

* fix: typings

* chore: update dependencies

* chore: remove unused import

* style: pretty

* merge on home pc

* refactor: use dependencies less

---------

Co-authored-by: jacoobes <jacobnguyend@gmail.com>
2023-04-10 22:12:26 -05:00
Jacob Nguyen
473be775f0 Update README.md 2023-03-29 15:12:26 -05:00
Neo
36af102251 docs: removed ALMA (#264)
Not working on it anymore, also not running it.
2023-03-29 12:55:16 -05:00
github-actions[bot]
cee740ea3f style: pretty please (#260)
Co-authored-by: renovate[bot] <renovate[bot]@users.noreply.github.com>
2023-03-17 17:01:20 -05:00
github-actions[bot]
2fd7697300 chore(main): release 2.6.1 (#258)
* chore(main): release 2.6.1

* 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>
2023-03-17 16:37:20 -05:00
Jacob Nguyen
f9609ce6cd chore: release 2.6.1
Release-As: 2.6.1
2023-03-17 16:33:46 -05:00
Jacob Nguyen
a3064aa915 chore: audit & remove tspattern (#256)
* chore: move import

* build: remove ts pattern

* fix: forgot to convert to switch

* fix workflow

* refactor: lift function out of readyHandler

* refactor: clean up errTap signature

* fix: sern emitter emitting wrong payload

* wa

* style: space

* chore: remove old errTap

* chore:bump discord.js

* chore: eslint format
2023-03-17 16:30:27 -05:00
github-actions[bot]
0a53a48521 style: pretty please (#255)
Co-authored-by: jacoobes <jacoobes@users.noreply.github.com>
2023-03-15 21:16:58 -05:00
Jacob Nguyen
05037b5315 build: prettier ignore 2023-03-15 21:15:33 -05:00
Jacob Nguyen
06a3e69210 feat: prettier ignore 2023-03-15 21:13:42 -05:00
Jacob Nguyen
74c4b77d4b build: refactor/building (#252)
* refactor: conditional compilation of loading esm/cjs modules

* refactor: move file loading file

* refactor: add conditional compilation for building modules

* refactor: add conditional compilation for building modules

* perf: decrease build times

* test

* revert: typo and clean code

* build: smaller build

* chore:cleanscripts

* chore:refactor readme

* build:automerge lockfile

* chore: remove build and upgrade readme

* fix: dropdown

* chore: fix

* chore: more docs

---------

Co-authored-by: jacoobes <jacobnguyend@gmail.com>
2023-03-15 21:08:27 -05:00
github-actions[bot]
d381ff568e chore(main): release 2.6.0 (#248)
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2023-03-09 16:18:06 -06:00
Jacob Nguyen
6db5c71506 chore: update workflow 2023-03-09 16:15:29 -06:00
github-actions[bot]
507c9e7939 style: pretty please (#247)
Co-authored-by: jacoobes <jacoobes@users.noreply.github.com>
2023-03-09 16:11:29 -06:00
Jacob Nguyen
09610d0501 refactor: eventhandlers (#246)
* refactor:import

* feat: save progress

* feat:progress

* refactor: event handlers

* fix: merge all subscriptions into event handler

* fix: remove duplicate minify key

* fix: leftover this

* docs: jsdoc

* chore: clean pnpm

---------

Co-authored-by: jacoobes <jacobnguyend@gmail.com>
2023-03-09 16:09:35 -06:00
github-actions[bot]
0862bf92d0 style: pretty please (#244)
Co-authored-by: renovate[bot] <renovate[bot]@users.noreply.github.com>
2023-03-03 09:22:02 -06:00
renovate[bot]
62162f6b8c chore(deps): lock file maintenance (#240)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-03-02 19:03:02 -06:00
renovate[bot]
eb501db09a chore(deps): update dependency @typescript-eslint/eslint-plugin to v5.54.0 (#243)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-02 19:01:18 -06:00
renovate[bot]
964848a4e2 chore(deps): update dependency @typescript-eslint/parser to v5.54.0 (#194)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-03-02 18:48:56 -06:00
renovate[bot]
78dead1b49 chore(deps): update pnpm to v7.28.0 (#239)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-03-02 18:48:32 -06:00
renovate[bot]
39e6d6d2f9 chore(deps): update dependency @typescript-eslint/eslint-plugin to v5.52.0 (#238)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-20 00:30:10 -06:00
renovate[bot]
5aff57ed6d chore(deps): update dependency typescript to v4.9.5 (#208)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2023-02-20 00:27:36 -06:00
github-actions[bot]
4095471346 style: pretty please (#237)
Co-authored-by: jacoobes <jacoobes@users.noreply.github.com>
2023-02-17 15:47:33 -06:00
Jacob Nguyen
f7b9c52df1 Merge remote-tracking branch 'origin/main' 2023-02-17 15:40:54 -06:00
Jacob Nguyen
d20d01524b feat: adding pure annotation for better tree shaking 2023-02-17 15:40:32 -06:00
renovate[bot]
5684c060bc chore(deps): lock file maintenance (#225)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
Co-authored-by: Jacob Nguyen <76754747+jacoobes@users.noreply.github.com>
2023-02-17 13:13:15 -06:00
94 changed files with 7006 additions and 3538 deletions

View File

@@ -1,13 +0,0 @@
{
"parser": "@typescript-eslint/parser",
"extends": ["plugin:@typescript-eslint/recommended"],
"parserOptions": { "ecmaVersion": "latest", "sourceType": "script" },
"rules": {
"@typescript-eslint/no-non-null-assertion": "off",
"quotes": [2, "single", { "avoidEscape": true, "allowTemplateLiterals": true }],
"semi": ["error", "always"],
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/ban-types": 0,
"@typescript-eslint/no-explicit-any": "off"
}
}

36
.github/SECURITY.md vendored
View File

@@ -6,13 +6,43 @@ Project is currently under heavy development but you can try out our [npm packag
| Version | Supported |
| ------- | ------------------ |
| 0.1.0 @ dev | :white_check_mark: |
| 2.6.1 | YES |
| 2.6.0 | YES |
| 2.5.3 | YES |
| 2.5.2 | YES |
| 2.5.1 | YES |
| 2.5.0 | YES |
| 2.1.1 | NO |
| 2.1.0 | NO |
| 2.0.0 | NO |
| 1.2.1 | NO |
| 1.2.0 | NO |
| 1.1.0 | NO |
1.0.1 | NO
1.0.0 | NO
1.1.9 @ beta | NO
1.1.8 @ beta | NO
1.1.7 @ beta | NO
1.1.6 @ beta | NO
1.1.5 @ beta | NO
1.1.4 @ beta | NO
1.1.3 @ beta | NO
1.1.2 @ beta | NO
1.1.1 @ beta | NO
1.1.0 @ beta | NO
1.0.4 @ beta | NO
1.0.3 @ beta | NO
1.0.2 @ beta | NO
1.0.1 @ beta | NO
1.0.0 @ beta | NO
0.0.1 @ dev | NO (TRY IT)
* Dev versions might include bugs, use it with your own risk.
* Dev versions might include bugs and not supported use stable versions.
## Reporting a Vulnerability
You can report a vulnerability by opening an issue on the [project's GitHub](https://github.com/SernHandler/Sern/issues) repository.
You can report a vulnerability by opening an issue on the [project's GitHub](https://github.com/sern-handler/handler/issues) repository.
Please provide as much information as possible when reporting a vulnerability. We are looking information for, the affected version, and the steps to reproduce the vulnerability.

View File

@@ -3,8 +3,10 @@ name: "CodeQL"
on:
push:
branches: [ main ]
paths: ["src/**/*"]
pull_request:
branches: [ main ]
paths: ["src/**/*"]
schedule:
- cron: '37 20 * * 4'

View File

@@ -3,13 +3,14 @@ name: Continuous Integration
on:
# Trigger the workflow on push or pull request or custom
push:
branches:
main
branches: [main]
paths:
- '**.ts'
- '*.ts'
pull_request_target:
branches:
main
paths:
- '*ts'
workflow_dispatch:
jobs:
@@ -19,7 +20,7 @@ jobs:
steps:
- name: Check out Git repository
uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3
uses: actions/checkout@8f4b7f84864484a7bf31766abe9204da3cbe65b3 # v3
- name: Set up Node.js
uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3
@@ -27,14 +28,14 @@ jobs:
node-version: 17
- name: Install pnpm
run: npm i -g pnpm
run: npm i -g yarn
# Prettier must be in `package.json`
- name: Install Node.js dependencies
run: pnpm i
run: yarn --immutable
- name: Run Prettier
run: pnpm run pretty
run: yarn pretty
- name: Create Pull Request
id: cpr

View File

@@ -1,10 +1,7 @@
name: NPM / Publish
on:
workflow_run:
workflows: ["release-please"]
types: [completed]
workflow_dispatch:
jobs:
test-and-publish:
runs-on: ubuntu-latest
@@ -13,15 +10,9 @@ jobs:
- uses: actions/setup-node@v3
with:
node-version: 17
- uses: pnpm/action-setup@v2
with:
run_install: |
- recursive: true
args: [--strict-peer-dependencies]
- run: pnpm install
- run: pnpm test
- run: pnpm build
- run: yarn --immutable
- run: yarn build:prod
- uses: JS-DevTools/npm-publish@v1
with:
token: ${{ secrets.NPM_TOKEN }}
access: "public"
access: "public"

4
.gitignore vendored
View File

@@ -87,3 +87,7 @@ dist
# IntelliJ IDEA Config file
.idea/
# Yarn files
.yarn/install-state.gz
.yarn/build-state.yml

View File

@@ -52,6 +52,7 @@ typings/
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
@@ -108,6 +109,8 @@ tsup.config.js
tsconfig-base.json
tsconfig.cjs.json
tsconfig-cjs.json
tsconfig.esm.json
tsconfig-esm.json
renovate.json

View File

@@ -1,8 +0,0 @@
{
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 4,
"arrowParens": "avoid"
}

873
.yarn/releases/yarn-3.5.0.cjs vendored Normal file

File diff suppressed because one or more lines are too long

5
.yarnrc.yml Normal file
View File

@@ -0,0 +1,5 @@
enableGlobalCache: true
nodeLinker: node-modules
yarnPath: .yarn/releases/yarn-3.5.0.cjs

View File

@@ -1,5 +1,19 @@
# Changelog
## [2.6.1](https://github.com/sern-handler/handler/compare/v2.6.0...v2.6.1) (2023-03-17)
### Miscellaneous Chores
* release 2.6.1 ([f9609ce](https://github.com/sern-handler/handler/commit/f9609ce6cd777fa0eb595d8c48d57905bbce5966))
## [2.6.0](https://github.com/sern-handler/handler/compare/v2.5.3...v2.6.0) (2023-03-09)
### Features
* adding pure annotation for better tree shaking ([d20d015](https://github.com/sern-handler/handler/commit/d20d01524b872549da501e21feec147ab204f397))
## [2.5.3](https://github.com/sern-handler/handler/compare/v2.5.2...v2.5.3) (2023-02-16)

View File

@@ -1,6 +1,6 @@
MIT License
**Copyright (c) 2022 Sern**
Copyright (c) 2023 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
@@ -19,5 +19,3 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
End license text.

View File

@@ -16,7 +16,7 @@
## Why?
- Most handlers don't support discord.js 14.7+
- Customizable commands
- Customizable, composable commands
- Plug and play or customize to your liking
- Embraces reactive programming for consistent and reliable backend
- Customizable logger, error handling, and more
@@ -44,27 +44,54 @@ pnpm add @sern/handler
```
## 👶 Basic Usage
<details open><summary>ping.ts</summary>
#### ` index.js (CommonJS)`
```js
// Import the discord.js Client and GatewayIntentBits
const { Client, GatewayIntentBits } = require('discord.js');
// Import Sern namespace
const { Sern, single } = require('@sern/handler');
const client = new Client({
intents: [
GatewayIntentBits.Guilds,
GatewayIntentBits.GuildMembers,
GatewayIntentBits.GuildMessages
]
```ts
export default commandModule({
type: CommandType.Slash,
//Installed plugin to publish to discord api and allow access to owners only.
plugins: [publish(), ownerOnly()],
description: 'A ping pong command',
execute(ctx) {
ctx.reply('Hello owner of the bot');
}
});
export const useContainer = Sern.makeDependencies({
```
</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) })
.upsert({ '@sern/logger': single(() => new DefaultLogging()) })
});
//View docs for all options
@@ -73,33 +100,20 @@ Sern.init({
commands: 'src/commands',
// events: 'src/events' (optional),
containerConfig : {
get: useContainer
get: useContainer
}
});
client.login("YOUR_BOT_TOKEN_HERE");
```
#### ` ping.js (CommonJS)`
```js
const { CommandType, commandModule } = require('@sern/handler');
exports.default = commandModule({
type: CommandType.Slash,
description: 'A ping pong command',
execute(ctx) {
ctx.reply('pong!');
}
});
```
</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.
- [ALMA (WIP)](https://github.com/Benzo-Fury/ALMA), Using AI to unleash the power in your server.
- [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.
## 💻 CLI
@@ -112,9 +126,7 @@ It is **highly encouraged** to use the [command line interface](https://github.c
- [Support Server](https://sern.dev/discord)
## 👋 Contribute
- Read our contribution [guidelines](https://github.com/sern-handler/handler/blob/main/.github/CONTRIBUTING.md) carefully
- Pull up on [issues](https://github.com/sern-handler/handler/issues) and report bugs
- All kinds of contributions are welcomed.

View File

@@ -1,25 +1,29 @@
{
"name": "@sern/handler",
"packageManager": "pnpm@7.27.0",
"version": "2.5.3",
"description": "A customizable, batteries-included, powerful discord.js framework to automate and streamline bot development.",
"main": "dist/cjs/index.cjs",
"module": "dist/esm/index.mjs",
"packageManager": "yarn@3.5.0",
"version": "3.0.0-rc1",
"description": "A complete, customizable, typesafe, & reactive framework for discord bots.",
"main": "./dist/esm/index.mjs",
"module": "./dist/cjs/index.cjs",
"types": "dist/index.d.ts",
"exports": {
".": {
"import": "./dist/esm/index.mjs",
"require": "./dist/cjs/index.cjs"
"require": "./dist/cjs/index.cjs",
"types": "./dist/index.d.ts"
}
},
"scripts": {
"watch": "tsup --dts --watch",
"watch": "tsup --watch",
"clean-modules": "rimraf node_modules/ && npm install",
"lint": "eslint src/**/*.ts",
"format": "eslint src/**/*.ts --fix",
"build": "tsup && node scripts/mkjson.mjs dist/cjs dist/esm && tsup --dts-only --outDir dist",
"publish": "npm run build && npm publish",
"pretty": "prettier --write ."
"build:dev": "tsup --metafile",
"build:prod": "tsup --minify",
"publish": "npm run build:prod",
"pretty": "prettier --write .",
"tdd": "vitest",
"test": "vitest --run"
},
"keywords": [
"sern-handler",
@@ -35,17 +39,56 @@
"dependencies": {
"iti": "^0.6.0",
"rxjs": "^7.8.0",
"ts-pattern": "^4.1.4",
"ts-results-es": "^3.5.0"
"ts-results-es": "^3.6.0"
},
"devDependencies": {
"@typescript-eslint/eslint-plugin": "5.51.0",
"@typescript-eslint/parser": "5.48.0",
"discord.js": "^14.7.1",
"eslint": "8.30.0",
"prettier": "2.8.4",
"tsup": "^6.6.3",
"typescript": "4.9.4"
"@faker-js/faker": "^8.0.1",
"@types/node": "^18.15.11",
"@typescript-eslint/eslint-plugin": "5.58.0",
"@typescript-eslint/parser": "5.58.0",
"discord.js": "14.11.0",
"esbuild-ifdef": "^0.2.0",
"eslint": "8.38.0",
"prettier": "2.8.7",
"tsup": "^6.7.0",
"typescript": "5.0.2",
"vitest": "latest"
},
"prettier": {
"semi": true,
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100,
"tabWidth": 4,
"arrowParens": "avoid"
},
"eslintConfig": {
"parser": "@typescript-eslint/parser",
"extends": [
"plugin:@typescript-eslint/recommended"
],
"parserOptions": {
"ecmaVersion": "latest",
"sourceType": "script"
},
"rules": {
"@typescript-eslint/no-non-null-assertion": "off",
"quotes": [
2,
"single",
{
"avoidEscape": true,
"allowTemplateLiterals": true
}
],
"semi": [
"error",
"always"
],
"@typescript-eslint/no-empty-interface": 0,
"@typescript-eslint/ban-types": 0,
"@typescript-eslint/no-explicit-any": "off"
}
},
"repository": {
"type": "git",

1788
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,6 +9,6 @@
"schedule": ["every weekend"],
"lockFileMaintenance": {
"enabled": true,
"automerge": false
"automerge": true
}
}

View File

@@ -1,13 +0,0 @@
import { writeFile } from 'fs/promises';
import { join } from 'path';
// A quick script to regenerate package.jsons for each cjs and esm after tsup cleans distributions
const locations = process.argv;
locations.shift();
locations.shift();
for (const loc of locations) {
if (loc.endsWith('cjs')) {
await writeFile(join(loc, 'package.json'), JSON.stringify({ type: 'commonjs' }));
} else {
await writeFile(join(loc, 'package.json'), JSON.stringify({ type: 'module' }));
}
}

View File

@@ -0,0 +1,21 @@
/**
* @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

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

View File

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

View File

@@ -0,0 +1,11 @@
/**
* @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

@@ -0,0 +1,14 @@
import { CommandType } from '../structures';
import { CommandMeta, CommandModule, CommandModuleDefs, Module } from '../types/modules';
/**
* @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

@@ -0,0 +1,9 @@
import { CommandMeta, Module } from '../types/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,8 +1,7 @@
import { CommandType, EventType, PluginType } from '../structures/enums';
import type { Plugin, PluginResult } from '../../types/plugin';
import type { CommandArgs, EventArgs } from './args';
import { CommandType, EventType, PluginType } from './structures';
import type { Plugin, PluginResult, EventArgs, CommandArgs } from './types/plugins';
import type { ClientEvents } from 'discord.js';
export const guayin = Symbol('twice<3');
export function makePlugin<V extends unknown[]>(
type: PluginType,
execute: (...args: any[]) => any,
@@ -10,28 +9,39 @@ export function makePlugin<V extends unknown[]>(
return {
type,
execute,
[guayin]: undefined,
} 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,
) {
@@ -39,6 +49,7 @@ export function EventControlPlugin<I extends EventType>(
}
/**
* @since 2.5.0
* @Experimental
* A specialized function for creating control plugins with discord.js ClientEvents.
* Will probably be moved one day!

View File

@@ -1,12 +1,37 @@
import { Err, Ok } from 'ts-results-es';
import { ApplicationCommandOptionType, AutocompleteInteraction } from 'discord.js';
import type { SernAutocompleteData, SernOptionsData } from '../../types/module';
import type { SernAutocompleteData, SernOptionsData } from './types/modules';
import { AnyCommandPlugin, AnyEventPlugin, Plugin } from './types/plugins';
import { PluginType } from './structures';
//function wrappers for empty ok / err
export const ok = /* @__PURE__*/ () => Ok.EMPTY;
export const err = /* @__PURE__*/ () => Err.EMPTY;
export function partitionPlugins(
arr: (AnyEventPlugin | AnyCommandPlugin)[] = [],
): [Plugin[], Plugin[]] {
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;
}
}
return [controlPlugins, initPlugins];
}
/**
* Uses an iterative DFS to check if an autocomplete node exists
* Uses an iterative DFS to check if an autocomplete node exists on the option tree
* @param iAutocomplete
* @param options
*/
export default function treeSearch(
export function treeSearch(
iAutocomplete: AutocompleteInteraction,
options: SernOptionsData[] | undefined,
): SernAutocompleteData | undefined {
@@ -18,19 +43,13 @@ export default function treeSearch(
const cur = _options.pop()!;
switch (cur.type) {
case ApplicationCommandOptionType.Subcommand:
case ApplicationCommandOptionType.SubcommandGroup:
{
for (const option of cur.options ?? []) {
_options.push(option);
}
}
break;
case ApplicationCommandOptionType.SubcommandGroup:
{
for (const command of cur.options ?? []) {
_options.push(command);
}
}
break;
default:
{
if (cur.autocomplete) {

28
src/core/index.ts Normal file
View File

@@ -0,0 +1,28 @@
export * from './contracts';
export * from './create-plugins';
export * from './structures';
export * from './ioc';
export type {
CommandModule,
EventModule,
BothCommand,
ContextMenuMsg,
ContextMenuUser,
SlashCommand,
TextCommand,
ButtonCommand,
StringSelectCommand,
MentionableSelectCommand,
UserSelectCommand,
ChannelSelectCommand,
RoleSelectCommand,
ModalSubmitCommand,
DiscordEventCommand,
SernEventCommand,
ExternalEventCommand,
CommandModuleDefs,
EventModuleDefs,
SernAutocompleteData,
SernOptionsData,
} from './types/modules';
export type { Controller, PluginResult, InitPlugin, ControlPlugin, Plugin } from './types/plugins';

34
src/core/ioc/base.ts Normal file
View File

@@ -0,0 +1,34 @@
import * as assert from 'assert';
import { composeRoot, useContainer } from './dependency-injection';
import { DependencyConfiguration } from './types';
import { CoreContainer } from '../structures/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>();
}

16
src/core/ioc/dependencies.d.ts vendored Normal file
View File

@@ -0,0 +1,16 @@
// This file serves an the interface for developers to augment the Dependencies interface
// Developers will have to create a new file dependencies.d.ts in the root directory, augmenting
// this type
/* eslint-disable @typescript-eslint/consistent-type-imports */
import { CoreDependencies } from './types'
declare global {
interface Dependencies extends CoreDependencies {}
}

View File

@@ -0,0 +1,80 @@
import type {
CoreDependencies,
DependencyConfiguration,
IntoDependencies,
} from './types';
import { DefaultLogging } from '../structures';
import { SernError } from '../structures/errors';
import { useContainerRaw } from './base';
import { CoreContainer } from '../structures/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;
}
export function Service<const T extends keyof Dependencies>(key: T) {
return useContainerRaw().get(key)!;
}
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 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>() {
console.warn(`
Warning: using a container hook (useContainer) is not recommended.
Could lead to many unwanted side effects.
Use the new Service(s) api function instead.
`);
return <V extends (keyof T)[]>(...keys: [...V]) =>
keys.map(key => useContainerRaw().get(key as keyof Dependencies)) as IntoDependencies<V>;
}

3
src/core/ioc/index.ts Normal file
View File

@@ -0,0 +1,3 @@
export { useContainerRaw, makeDependencies } from './base';
export { Service, Services, single, transient } from './dependency-injection';
export type { Singleton, Transient, CoreDependencies } from './types';

26
src/core/ioc/types.ts Normal file
View File

@@ -0,0 +1,26 @@
import { EventEmitter } from 'node:events';
import { Container, UnpackFunction } from 'iti';
export type Singleton<T> = () => T;
export type Transient<T> = () => () => T;
export interface CoreDependencies {
'@sern/client': () => EventEmitter
'@sern/logger'?: () => import('../contracts').Logging;
'@sern/emitter': () => import('../structures/sern-emitter').SernEmitter;
'@sern/store': () => import('../contracts').CoreModuleStore;
'@sern/modules': () => import('../contracts').ModuleManager;
'@sern/errors': () => import('../contracts').ErrorHandling;
}
export type DependencyFromKey<T extends keyof Dependencies> = Dependencies[T];
export type IntoDependencies<Tuple extends [...any[]]> = {
[Index in keyof Tuple]: UnpackFunction<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, {}>;
}

103
src/core/module-loading.ts Normal file
View File

@@ -0,0 +1,103 @@
import { SernError } from './structures/errors';
import { Result, Err, Ok } from 'ts-results-es';
import { Module } from './types/modules';
import { type Observable, from, mergeMap, ObservableInput } from 'rxjs';
import { readdir, stat } from 'fs/promises';
import { basename, extname, join, resolve } from 'path';
import { ImportPayload } from '../handler/types';
export type ModuleResult<T> = Promise<Result<ImportPayload<T>, SernError>>;
export async function importModule<T>(absPath: string) {
// prettier-ignore
let module =
/// #if MODE === 'esm'
import(absPath).then(i => i.default); // eslint-disable-line
/// #elif MODE === 'cjs'
require(absPath).default; // eslint-disable-line
/// #endif
return module.then(m =>
Result
.wrap(() => m.getInstance())
.unwrapOr(m)
) as T;
}
export async function defaultModuleLoader<T extends Module>(absPath: string): ModuleResult<T> {
let module = await importModule<T>(absPath);
if (module === undefined) {
return Err(SernError.UndefinedModule);
}
//todo readd class modules
return Ok({ 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<Result<ImportPayload<T>, SernError>> {
return from(input).pipe(mergeMap(defaultModuleLoader<T>));
}
export function getFullPathTree(dir: string, mode: boolean) {
return readPaths(resolve(dir), mode);
}
export function filename(path: string) {
return fmtFileName(basename(path));
}
function createSkipCondition(base: string) {
const validExtensions = ['.js', '.cjs', '.mts', '.mjs', 'cts'];
return ( type: 'file' | 'directory') => {
if(type === 'file') {
return fmtFileName(base)[0] === '!'
|| !validExtensions.includes(extname(base));
}
return base[0] === '!';
}
}
async function deriveFileInfo(dir: string, file: string) {
const fullPath = join(dir, file);
return {
fullPath,
fileStats: await stat(fullPath),
base: basename(file)
}
}
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);
const isSkippable = createSkipCondition(base);
if (fileStats.isDirectory()) {
//Todo: refactor so that i dont repeat myself for files (line 71)
if (isSkippable('directory')) {
if (shouldDebug) console.info(`ignored directory: ${fullPath}`);
} else {
yield* readPaths(fullPath, shouldDebug);
}
} else {
if (isSkippable('file')) {
if (shouldDebug) console.info(`ignored: ${fullPath}`);
} else {
/// #if MODE === 'esm'
yield 'file:///' + fullPath;
/// #elif MODE === 'cjs'
yield fullPath;
/// #endif
}
}
}
} catch (err) {
throw err;
}
}

106
src/core/operators.ts Normal file
View File

@@ -0,0 +1,106 @@
/**
* 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,
switchMap,
} from 'rxjs';
import { Result } from 'ts-results-es';
import { EventEmitter } from 'node:events';
import { ErrorHandling, Logging } from './contracts';
import util from 'node:util';
import { Awaitable } from '../shared';
import { PluginResult, VoidResult } from './types/plugins';
/**
* 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));
}
export function filterMap<In, Out>(
cb: (i: In) => Awaitable<Result<Out, unknown>>,
): OperatorFunction<In, Out> {
return pipe(
switchMap(async input => cb(input)),
concatMap(s => {
if (s.ok) {
return of(s.val);
}
return 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]));
/**
* If the current value in Result stream is an error, calls callback.
* This also extracts the Ok value from Result
* @param cb
* @returns Observable<{ module: T; absPath: string }>
*/
export function errTap<Ok, Err>(cb: (err: Err) => void): OperatorFunction<Result<Ok, Err>, Ok> {
return concatMap(result => {
if (result.ok) {
return of(result.val);
} else {
cb(result.val as Err);
return EMPTY;
}
});
}
/**
* Checks if the stream of results is all ok.
*/
export const everyPluginOk: OperatorFunction<VoidResult, boolean> = pipe(
every(result => result.ok),
defaultIfEmpty(true),
);
export const sharedObservable = <T>(e: EventEmitter, 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;
};
}

34
src/core/predicates.ts Normal file
View File

@@ -0,0 +1,34 @@
import {
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;
}

View File

@@ -0,0 +1,62 @@
import { Container } from 'iti';
import { DefaultErrorHandling, DefaultModuleManager, SernEmitter } from '../';
import { isAsyncFunction } from 'node:util/types';
import * as assert from 'node:assert';
import { Subject } from 'rxjs';
import { ModuleStore } from './module-store';
/**
* 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>();
constructor() {
super();
this.listenForInsertions();
(this as Container<{}, {}>)
.add({
'@sern/errors': () => new DefaultErrorHandling(),
'@sern/emitter': () => new SernEmitter(),
'@sern/store': () => new ModuleStore(),
})
.add(ctx => {
return { '@sern/modules': () => new 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', this.callInitHooks);
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') {
isAsyncFunction(dep.init) ? await dep.init() : dep.init();
}
}
isReady() {
return this.ready$.closed;
}
ready() {
this.ready$.unsubscribe();
}
}

View File

@@ -1,41 +1,32 @@
import type {
import {
BaseInteraction,
ChatInputCommandInteraction,
Client,
InteractionReplyOptions,
Message,
Snowflake,
MessageReplyOptions,
Snowflake,
User,
} from 'discord.js';
import { Result as Either, Ok as Left, Err as Right } from 'ts-results-es';
import type { ReplyOptions } from '../../types/handler';
import { SernError } from './errors';
import { CoreContext } from './core-context';
import { Result, Ok, Err } from 'ts-results-es';
import * as assert from 'assert';
import { ReplyOptions } from '../../shared';
function safeUnwrap<T>(res: Either<T, T>) {
return res.val;
}
/**
* @since 1.0.0
* Provides values shared between
* Message and ChatInputCommandInteraction
*/
export default class Context {
private constructor(private ctx: Either<Message, ChatInputCommandInteraction>) {}
/**
* Getting the Message object. Crashes if module type is
* CommandType.Slash or the event fired in a Both command was
* ChatInputCommandInteraction
export class Context extends CoreContext<Message, ChatInputCommandInteraction> {
/*
* @Experimental
*/
public get message() {
return this.ctx.expect(SernError.MismatchEvent);
get options() {
return this.interaction.options;
}
/**
* Getting the ChatInputCommandInteraction object. Crashes if module type is
* CommandType.Text or the event fired in a Both command was
* Message
*/
public get interaction() {
return this.ctx.expectErr(SernError.MismatchEvent);
protected constructor(protected ctx: Result<Message, ChatInputCommandInteraction>) {
super(ctx);
}
public get id(): Snowflake {
@@ -64,7 +55,6 @@ export default class Context {
public get guildId() {
return this.ctx.val.guildId;
}
/*
* interactions can return APIGuildMember if the guild it is emitted from is not cached
*/
@@ -79,22 +69,8 @@ export default class Context {
public get inGuild(): boolean {
return this.ctx.val.inGuild();
}
public isMessage() {
return this.ctx.map(() => true).unwrapOr(false);
}
public isSlash() {
return !this.isMessage();
}
static wrap(wrappable: ChatInputCommandInteraction | Message): Context {
if ('token' in wrappable) {
return new Context(Right(wrappable));
}
return new Context(Left(wrappable));
}
public reply(content: ReplyOptions) {
public async reply(content: ReplyOptions) {
return safeUnwrap(
this.ctx
.map(m => m.reply(content as string | MessageReplyOptions))
@@ -103,4 +79,16 @@ export default class Context {
),
);
}
static override wrap(wrappable: BaseInteraction | Message): Context {
if ('interaction' in wrappable) {
return new Context(Ok(wrappable));
}
assert.ok(wrappable.isChatInputCommand());
return new Context(Err(wrappable));
}
}
function safeUnwrap<T>(res: Result<T, T>) {
return res.val;
}

View File

@@ -0,0 +1,34 @@
import { Result as Either } from 'ts-results-es';
import { SernError } from './errors';
import * as assert from 'node:assert';
/**
* @since 3.0.0
*/
export abstract class CoreContext<M, I> {
protected constructor(protected ctx: Either<M, I>) {
assert.ok(typeof ctx.val === 'object' && ctx.val != 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;
}
public isSlash(): this is CoreContext<never, I> {
return !this.isMessage();
}
//todo: add agnostic options resolver for Context
abstract get options(): unknown;
abstract get id(): string;
static wrap(_: unknown): unknown {
throw Error('You need to override this method; cannot wrap an abstract class');
}
}

View File

@@ -1,4 +1,5 @@
/**
* @since 1.0.0
* A bitfield that discriminates command modules
* @enum { number }
* @example
@@ -14,45 +15,18 @@
* ```
*/
export enum CommandType {
/**
* The CommandType for text commands
*/
Text = 1,
/**
* The CommandType for slash commands
*/
Slash = 2,
/**
* The CommandType for hybrid commands, text and slash
*/
Text = 1 << 0,
Slash = 1 << 1,
Both = 3,
/**
* The CommandType for UserContextMenuInteraction commands
*/
CtxUser = 4,
/**
* The CommandType for MessageContextMenuInteraction commands
*/
CtxMsg = 8,
/**
* The CommandType for ButtonInteraction commands
*/
Button = 16,
/**
* The CommandType for StringSelectMenuInteraction commands
*/
StringSelect = 32,
/**
* The CommandType for ModalSubmitInteraction commands
*/
Modal = 64,
/**
* The CommandType for the other SelectMenuInteractions
*/
ChannelSelect = 256,
MentionableSelect = 512,
RoleSelect = 1024,
UserSelect = 2048,
CtxUser = 1 << 2,
CtxMsg = 1 << 3,
Button = 1 << 4,
StringSelect = 1 << 5,
Modal = 1 << 6,
ChannelSelect = 1 << 7,
MentionableSelect = 1 << 8,
RoleSelect = 1 << 9,
UserSelect = 1 << 10,
}
/**
@@ -105,16 +79,6 @@ export enum PluginType {
* The PluginType for InitPlugins
*/
Init = 1,
/**
* @deprecated
* Use PluginType.Init instead
*/
Command = 1,
/**
* @deprecated
* Use PluginType.Control instead
*/
Event = 2,
/**
* The PluginType for EventPlugins
*/

View File

@@ -1,7 +1,7 @@
/**
* @enum { string }
*/
export enum SernError {
export const enum SernError {
/**
* Throws when registering an invalid module.
* This means it is undefined or an invalid command type was provided

View File

@@ -0,0 +1,5 @@
export * from './enums';
export * from './context';
export * from './sern-emitter';
export * from './services';
export * from './module-store';

View File

@@ -0,0 +1,12 @@
import { CoreModuleStore } from '../contracts';
import { Module, CommandMeta } from '../types/modules';
/*
* @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,9 +1,15 @@
import { EventEmitter } from 'events';
import type { Payload, SernEventsMapping } from '../types/handler';
import { PayloadType } from './structures';
import type { Module } from '../types/module';
import { EventEmitter } from 'node:events';
import { PayloadType } from '../../core/structures';
import { Payload, SernEventsMapping } from '../../shared';
import { Module } from '../types/modules';
class SernEmitter extends EventEmitter {
/**
* @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
@@ -81,5 +87,3 @@ class SernEmitter extends EventEmitter {
);
}
}
export default SernEmitter;

View File

@@ -0,0 +1,22 @@
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

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

View File

@@ -1,12 +1,10 @@
import type { LogPayload } from '../../types/handler';
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;
}
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 {

View File

@@ -0,0 +1,51 @@
import { createId } from '../../../handler/id';
import { CoreModuleStore, ModuleManager } from '../../contracts';
import { importModule } from '../../module-loading';
import { CommandMeta, CommandModule, CommandModuleDefs, Module } from '../../types/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(createId(name, commandType));
if(!id) {
return undefined;
}
return 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 ' + maybeModule);
}
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]) => importModule<CommandModule>(path)),
);
}
}

View File

@@ -1,34 +1,35 @@
import type {
ApplicationCommandAttachmentOption,
ApplicationCommandChannelOptionData,
ApplicationCommandChoicesData,
ApplicationCommandNonOptionsData,
ApplicationCommandNumericOptionData,
ApplicationCommandOptionData,
APIApplicationCommandBasicOption,
APIApplicationCommandOptionBase,
ApplicationCommandOptionType,
ApplicationCommandSubCommandData,
ApplicationCommandSubGroupData,
AutocompleteInteraction,
Awaitable,
BaseApplicationCommandOptionsData,
} from 'discord.js';
import {
AutocompleteInteraction,
ButtonInteraction,
ChannelSelectMenuInteraction,
ClientEvents,
MentionableSelectMenuInteraction,
MessageContextMenuCommandInteraction,
ModalSubmitInteraction,
UserContextMenuCommandInteraction,
ChannelSelectMenuInteraction,
MentionableSelectMenuInteraction,
RoleSelectMenuInteraction,
StringSelectMenuInteraction,
UserContextMenuCommandInteraction,
UserSelectMenuInteraction,
} from 'discord.js';
import { CommandType } from '../handler/structures/enums';
import type { Args, SlashOptions } from './handler';
import type Context from '../handler/structures/context';
import type { InitPlugin, ControlPlugin } from './plugin';
import { EventType } from '../handler/structures/enums';
import type { UserSelectMenuInteraction } from 'discord.js';
import type { AnyCommandPlugin, AnyEventPlugin } from './plugin';
import type { SernEventsMapping } from './handler';
import type { ClientEvents } from 'discord.js';
import { CommandType, Context, EventType } from '../structures';
import { AnyCommandPlugin, AnyEventPlugin, ControlPlugin, InitPlugin } from './plugins';
import { Awaitable, SernEventsMapping } from '../../shared';
import { Processed } from '../../handler/types';
import { Args, SlashOptions } from '../../shared';
export interface CommandMeta {
fullPath: string;
id: string;
isClass: boolean
}
export type AnyDefinedModule = Processed<CommandModule | EventModule>;
export interface Module {
type: CommandType | EventType;
@@ -39,25 +40,17 @@ export interface Module {
execute: (...args: any[]) => Awaitable<any>;
}
export interface TextCommand extends Module {
type: CommandType.Text;
alias?: string[];
execute: (ctx: Context, args: ['text', string[]]) => Awaitable<unknown>;
export interface SernEventCommand<T extends keyof SernEventsMapping = keyof SernEventsMapping>
extends Module {
name?: T;
type: EventType.Sern;
execute(...args: SernEventsMapping[T]): Awaitable<unknown>;
}
export interface SlashCommand extends Module {
type: CommandType.Slash;
description: string;
options?: SernOptionsData[];
execute: (ctx: Context, args: ['slash', SlashOptions]) => Awaitable<unknown>;
}
export interface BothCommand extends Module {
type: CommandType.Both;
alias?: string[];
description: string;
options?: SernOptionsData[];
execute: (ctx: Context, args: Args) => Awaitable<unknown>;
export interface ExternalEventCommand extends Module {
name?: string;
emitter: keyof Dependencies;
type: EventType.External;
execute(...args: unknown[]): Awaitable<unknown>;
}
export interface ContextMenuUser extends Module {
@@ -111,25 +104,31 @@ export interface AutocompleteCommand
execute: (ctx: AutocompleteInteraction) => Awaitable<unknown>;
}
export interface SernEventCommand<T extends keyof SernEventsMapping = keyof SernEventsMapping>
extends Module {
name?: T;
type: EventType.Sern;
execute(...args: SernEventsMapping[T]): Awaitable<unknown>;
}
export interface DiscordEventCommand<T extends keyof ClientEvents = keyof ClientEvents>
extends Module {
name?: T;
type: EventType.Discord;
execute(...args: ClientEvents[T]): Awaitable<unknown>;
}
export interface TextCommand extends Module {
type: CommandType.Text;
alias?: string[];
execute: (ctx: Context, args: ['text', string[]]) => Awaitable<unknown>;
}
export interface ExternalEventCommand extends Module {
name?: string;
emitter: string;
type: EventType.External;
execute(...args: unknown[]): Awaitable<unknown>;
export interface SlashCommand extends Module {
type: CommandType.Slash;
description: string;
options?: SernOptionsData[];
execute: (ctx: Context, args: ['slash', SlashOptions]) => Awaitable<unknown>;
}
export interface BothCommand extends Module {
type: CommandType.Both;
alias?: string[];
description: string;
options?: SernOptionsData[];
execute: (ctx: Context, args: Args) => Awaitable<unknown>;
}
export type EventModule = DiscordEventCommand | SernEventCommand | ExternalEventCommand;
@@ -151,7 +150,7 @@ export type AnyModule = CommandModule | EventModule;
//https://stackoverflow.com/questions/64092736/alternative-to-switch-statement-for-typescript-discriminated-union
// Explicit Module Definitions for mapping
export type CommandModuleDefs = {
export interface CommandModuleDefs {
[CommandType.Text]: TextCommand;
[CommandType.Slash]: SlashCommand;
[CommandType.Both]: BothCommand;
@@ -164,13 +163,13 @@ export type CommandModuleDefs = {
[CommandType.MentionableSelect]: MentionableSelectCommand;
[CommandType.UserSelect]: UserSelectCommand;
[CommandType.Modal]: ModalSubmitCommand;
};
}
export type EventModuleDefs = {
export interface EventModuleDefs {
[EventType.Sern]: SernEventCommand;
[EventType.Discord]: DiscordEventCommand;
[EventType.External]: ExternalEventCommand;
};
}
export interface SernAutocompleteData
extends Omit<BaseApplicationCommandOptionsData, 'autocomplete'> {
@@ -182,10 +181,10 @@ export interface SernAutocompleteData
command: AutocompleteCommand;
}
export type CommandModuleNoPlugins = {
type CommandModuleNoPlugins = {
[T in CommandType]: Omit<CommandModuleDefs[T], 'plugins' | 'onEvent'>;
};
export type EventModulesNoPlugins = {
type EventModulesNoPlugins = {
[T in EventType]: Omit<EventModuleDefs[T], 'plugins' | 'onEvent'>;
};
@@ -197,32 +196,22 @@ export type InputCommand = {
[T in CommandType]: CommandModuleNoPlugins[T] & { plugins?: AnyCommandPlugin[] };
}[CommandType];
/**
* Type that replaces autocomplete with {@link SernAutocompleteData}
*/
export type BaseOptions =
| ApplicationCommandChoicesData
| ApplicationCommandNonOptionsData
| ApplicationCommandChannelOptionData
| ApplicationCommandNumericOptionData
| ApplicationCommandAttachmentOption
export type SernOptionsData =
| SernSubCommandData
| SernSubCommandGroupData
| APIApplicationCommandBasicOption
| SernAutocompleteData;
export interface SernSubCommandData extends BaseApplicationCommandOptionsData {
export interface SernSubCommandData extends APIApplicationCommandOptionBase<ApplicationCommandOptionType.Subcommand> {
type: ApplicationCommandOptionType.Subcommand;
required?: never;
options?: BaseOptions[];
options?: SernOptionsData[];
}
export interface SernSubCommandGroupData extends BaseApplicationCommandOptionsData {
type: ApplicationCommandOptionType.SubcommandGroup;
required?: never;
options?: SernSubCommandData[];
}
export type SernOptionsData<U extends ApplicationCommandOptionData = ApplicationCommandOptionData> =
U extends ApplicationCommandSubCommandData
? SernSubCommandData
: U extends ApplicationCommandSubGroupData
? SernSubCommandGroupData
: BaseOptions;

View File

@@ -1,12 +1,26 @@
import type { CommandType } from '../structures/enums';
import type { PluginType } from '../structures/enums';
import type { ClientEvents } from 'discord.js';
/*
* Plugins can be inserted on all commands and are emitted
*
* 1. On ready event, where all commands are loaded.
* 2. On corresponding observable (when command triggers)
*
* The goal of plugins is to organize commands and
* provide extensions to repetitive patterns
* examples include refreshing modules,
* categorizing commands, cool-downs, permissions, etc.
* Plugins are reminiscent of middleware in express.
*/
import type { Err, Ok, Result } from 'ts-results-es';
import type {
BothCommand,
ButtonCommand,
ChannelSelectCommand,
CommandModule,
ContextMenuMsg,
ContextMenuUser,
DiscordEventCommand,
EventModule,
ExternalEventCommand,
MentionableSelectCommand,
ModalSubmitCommand,
@@ -16,27 +30,58 @@ import type {
StringSelectCommand,
TextCommand,
UserSelectCommand,
ContextMenuMsg,
Module,
} from '../../types/module';
import type { Args, Payload, Processed, SlashOptions } from '../../types/handler';
import type Context from '../structures/context';
import type { MessageContextMenuCommandInteraction } from 'discord.js';
import type {
} from './modules';
import { Args, Awaitable, Payload, SlashOptions } from '../../shared';
import { CommandType, Context, EventType, PluginType } from '../structures';
import { InitArgs, Processed } from '../../handler/types';
import {
ButtonInteraction,
ChannelSelectMenuInteraction,
ClientEvents,
MentionableSelectMenuInteraction,
MessageContextMenuCommandInteraction,
ModalSubmitInteraction,
RoleSelectMenuInteraction,
StringSelectMenuInteraction,
UserContextMenuCommandInteraction,
} from 'discord.js';
import type {
ChannelSelectMenuInteraction,
MentionableSelectMenuInteraction,
ModalSubmitInteraction,
UserSelectMenuInteraction,
} from 'discord.js';
import { EventType } from '../structures/enums';
type CommandArgsMatrix = {
export type PluginResult = Awaitable<VoidResult>;
export type VoidResult = Result<void, void>;
export interface Controller {
next: () => Ok<void>;
stop: () => Err<void>;
}
export interface Plugin<Args extends any[] = any[]> {
type: PluginType;
execute: (...args: Args) => PluginResult;
}
export interface InitPlugin<Args extends any[] = any[]> {
type: PluginType.Init;
execute: (...args: Args) => PluginResult;
}
export interface ControlPlugin<Args extends any[] = any[]> {
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 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];
interface CommandArgsMatrix {
[CommandType.Text]: {
[PluginType.Control]: [Context, ['text', string[]]];
[PluginType.Init]: [InitArgs<Processed<TextCommand>>];
@@ -85,9 +130,9 @@ type CommandArgsMatrix = {
[PluginType.Control]: [/* library coupled */ ModalSubmitInteraction];
[PluginType.Init]: [InitArgs<Processed<ModalSubmitCommand>>];
};
};
}
type EventArgsMatrix = {
interface EventArgsMatrix {
[EventType.Discord]: {
[PluginType.Control]: /* library coupled */ ClientEvents[keyof ClientEvents];
[PluginType.Init]: [InitArgs<Processed<DiscordEventCommand>>];
@@ -100,18 +145,4 @@ type EventArgsMatrix = {
[PluginType.Control]: unknown[];
[PluginType.Init]: [InitArgs<Processed<ExternalEventCommand>>];
};
};
export interface InitArgs<T extends Processed<Module>> {
module: T;
absPath: string;
}
export type CommandArgs<
I extends CommandType = CommandType,
J extends PluginType = PluginType,
> = CommandArgsMatrix[I][J];
export type EventArgs<
I extends EventType = EventType,
J extends PluginType = PluginType,
> = EventArgsMatrix[I][J];

112
src/handler/commands.ts Normal file
View File

@@ -0,0 +1,112 @@
import { ClientEvents } from 'discord.js';
import { CommandType, EventType, PluginType } from '../core/structures';
import {
AnyCommandPlugin,
AnyEventPlugin,
CommandArgs,
ControlPlugin,
EventArgs,
InitPlugin,
} from '../core/types/plugins';
import {
CommandModule,
EventModule,
InputCommand,
InputEvent,
Module,
} from '../core/types/modules';
import { partitionPlugins } from '../core/functions';
import { Awaitable } from '../shared';
/**
* @since 1.0.0 The wrapper function to define command modules for sern
* @param mod
*/
export function commandModule(mod: InputCommand): CommandModule {
const [onEvent, plugins] = partitionPlugins(mod.plugins);
return {
...mod,
onEvent,
plugins,
} as CommandModule;
}
/**
* @since 1.0.0
* The wrapper function to define event modules for sern
* @param mod
*/
export function eventModule(mod: InputEvent): EventModule {
const [onEvent, plugins] = partitionPlugins(mod.plugins);
return {
...mod,
plugins,
onEvent,
} as EventModule;
}
/** 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[];
execute: (...args: ClientEvents[T]) => Awaitable<unknown>;
}) {
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;
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,45 +0,0 @@
import type { Observable } from 'rxjs';
import type { Logging } from './logging';
import util from 'util';
export interface ErrorHandling {
/**
* Number of times the process should throw an error until crashing and exiting
*/
keepAlive: number;
/**
* Utility function to crash
* @param error
*/
crash(error: Error): never;
/**
* A function that is called on every crash. Updates keepAlive
* @param error
*/
updateAlive(error: Error): void;
}
export class DefaultErrorHandling implements ErrorHandling {
keepAlive = 5;
crash(error: Error): never {
throw error;
}
updateAlive(e: Error) {
this.keepAlive--;
}
}
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.format(pload));
if (crashHandler.keepAlive == 0) {
crashHandler.crash(err);
}
//formatted payload
logging?.error({ message: util.format(pload) });
crashHandler.updateAlive(err);
return caught;
};
}

View File

@@ -1,3 +0,0 @@
export { ErrorHandling, DefaultErrorHandling } from './errorHandling';
export { Logging, DefaultLogging } from './logging';
export { ModuleManager, DefaultModuleManager } from './moduleManager';

View File

@@ -1,23 +0,0 @@
import type { CommandModuleDefs } from '../../types/module';
import type { CommandType, ModuleStore } from '../structures';
import type { Processed } from '../../types/handler';
export interface ModuleManager {
get<T extends CommandType>(
strat: (ms: ModuleStore) => Processed<CommandModuleDefs[T]> | undefined,
): CommandModuleDefs[T] | undefined;
set(strat: (ms: ModuleStore) => void): void;
}
export class DefaultModuleManager implements ModuleManager {
constructor(private moduleStore: ModuleStore) {}
get<T extends CommandType>(
strat: (ms: ModuleStore) => Processed<CommandModuleDefs[T]> | undefined,
) {
return strat(this.moduleStore);
}
set(strat: (ms: ModuleStore) => void): void {
strat(this.moduleStore);
}
}

View File

@@ -1,2 +0,0 @@
export { single, transient, many } from './lifetimeFunctions';
export { useContainerRaw } from './provider';

View File

@@ -1,57 +0,0 @@
import { _const } from '../utilities/functions';
type NotFunction =
| string
| number
| boolean
| null
| undefined
| bigint
| readonly any[]
| { apply?: never; [k: string]: any }
| { call?: never; [k: string]: any };
/**
* @deprecated
* @param cb
*/
export function single<T extends NotFunction>(cb: T): () => T;
/**
* New signature
* @param cb
*/
export function single<T extends () => unknown>(cb: T): T;
/**
* Please note that on intellij, the deprecation is for all signatures, which is unintended behavior (and
* very annoying).
* For future versions, ensure that single is being passed as a **callback!!**
* @param cb
*/
export function single<T>(cb: T) {
if (typeof cb === 'function') return cb;
return () => cb;
}
/**
* @deprecated
* @param cb
* Deprecated signature
*/
export function transient<T extends NotFunction>(cb: T): () => () => T;
export function transient<T extends () => () => unknown>(cb: T): T;
/**
* Following iti's singleton and transient implementation,
* use transient if you want a new dependency every time your container getter is called
* @param cb
*/
export function transient<T>(cb: (() => () => T) | T) {
if (typeof cb !== 'function') return () => () => cb;
return cb;
}
/**
* @deprecated
* @param value
* Please use the transient function instead
*/
// prettier-ignore
export const many = <T>(value: T) => () => _const(value);

View File

@@ -1,73 +0,0 @@
import type { Container } from 'iti';
import { SernError } from '../structures/errors';
import type { Dependencies, DependencyConfiguration, MapDeps } from '../../types/handler';
import SernEmitter from '../sernEmitter';
import { DefaultErrorHandling, DefaultLogging, DefaultModuleManager } from '../contracts';
import { ModuleStore } from '../structures/moduleStore';
import { Result } from 'ts-results-es';
import { BehaviorSubject } from 'rxjs';
import { createContainer } from 'iti';
export const containerSubject = new BehaviorSubject(defaultContainer());
/**
* Given the user's conf, check for any excluded dependency keys.
* Then, call conf.build to get the rest of the users' dependencies.
* Finally, update the containerSubject with the new container state
* @param conf
*/
export function composeRoot<T extends Dependencies>(conf: DependencyConfiguration<T>) {
//Get the current container. This should have no client or possible logger yet.
const currentContainer = containerSubject.getValue();
const excludeLogger = conf.exclude?.has('@sern/logger');
if (!excludeLogger) {
currentContainer.add({
'@sern/logger': () => new DefaultLogging(),
});
}
//Build the container based on the callback provided by the user
const container = conf.build(currentContainer);
//Check if the built container contains @sern/client or throw
// a runtime exception
Result.wrap(() => container.get('@sern/client')).expect(SernError.MissingRequired);
if (!excludeLogger) {
container.get('@sern/logger')?.info({ message: 'All dependencies loaded successfully.' });
}
//I'm sorry little one
containerSubject.next(container as any);
}
export function useContainer<T extends Dependencies>() {
const container = containerSubject.getValue() as Container<T, {}>;
return <V extends (keyof T)[]>(...keys: [...V]) =>
keys.map(key => Result.wrap(() => container.get(key)).unwrapOr(undefined)) as MapDeps<T, V>;
}
/**
* Returns the underlying data structure holding all dependencies.
* Please be careful as this only gets the client's current state.
* Exposes some methods from iti
*/
export function useContainerRaw<T extends Dependencies>() {
return containerSubject.getValue() as Container<T, {}>;
}
/**
* Provides all the defaults for sern to function properly.
* The only user provided dependency needs to be @sern/client
*/
function defaultContainer() {
return createContainer()
.add({ '@sern/errors': () => new DefaultErrorHandling() })
.add({ '@sern/store': () => new ModuleStore() })
.add(ctx => {
return {
'@sern/modules': () => new DefaultModuleManager(ctx['@sern/store']),
};
})
.add({ '@sern/emitter': () => new SernEmitter() }) as Container<
Omit<Dependencies, '@sern/client' | '@sern/logger'>,
{}
>;
}

View File

@@ -0,0 +1,119 @@
import { EventEmitter } from 'node:events';
import * as assert from 'node:assert';
import { concatMap, from, fromEvent, map, OperatorFunction, pipe } from 'rxjs';
import { arrayifySource, callPlugin } from '../../core/operators';
import { createResultResolver } from './generic';
import { AutocompleteInteraction, BaseInteraction, Message } from 'discord.js';
import { treeSearch } from '../../core/functions';
import { SernError } from '../../core/structures/errors';
import { CommandType, Context } from '../../core';
import { isAutocomplete } from '../../core/predicates';
import { Processed } from '../types';
import { BothCommand, CommandModule, Module } from '../../core/types/modules';
import { Args } from '../../shared';
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);
if (option !== undefined) {
return {
module: option.command as Processed<Module>, //autocomplete is not a true "module" warning cast!
args: [payload.event],
};
}
throw Error(
SernError.NotSupportedInteraction + ` There is no autocomplete tag for this option`,
);
}
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;
}) {
switch (payload.module.type) {
case CommandType.Text:
throw Error(
SernError.MismatchEvent +
' Found a text module in interaction stream. ' +
payload.module,
);
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,64 +0,0 @@
import type { Processed } from '../../../types/handler';
import type { AutocompleteInteraction } from 'discord.js';
import { SernError } from '../../structures';
import treeSearch from '../../utilities/treeSearch';
import type { BothCommand, CommandModule, Module, SlashCommand } from '../../../types/module';
import { EventEmitter } from 'events';
import * as assert from 'assert';
import { concatMap, from, fromEvent, map, OperatorFunction, pipe } from 'rxjs';
import { callPlugin } from '../operators';
import { createResultResolver } from '../observableHandling';
export function dispatchCommand(module: Processed<CommandModule>, createArgs: () => unknown[]) {
const args = createArgs();
return {
module,
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`);
/**
* Sometimes fromEvent emits a single parameter, which is not an Array. This
* operator function flattens events into an array
* @param src
*/
const arrayify = pipe(
map(event => (Array.isArray(event) ? (event as unknown[]) : [event])),
map(args => ({ module, args })),
);
const createResult = createResultResolver<
Processed<Module>,
{ module: Processed<Module>; args: unknown[] },
unknown[]
>({
createStream: ({ module, args }) => from(module.onEvent).pipe(callPlugin(args)),
onSuccess: ({ args }) => args,
});
const execute: OperatorFunction<unknown[], unknown> = pipe(
concatMap(async args => module.execute(...args)),
);
return fromEvent(source, module.name).pipe(arrayify, concatMap(createResult), execute);
}
export function dispatchAutocomplete(
module: Processed<BothCommand | SlashCommand>,
interaction: AutocompleteInteraction,
) {
const option = treeSearch(interaction, module.options);
if (option !== undefined) {
return {
module: option.command as Processed<Module>, //autocomplete is not a true "module" warning cast!
args: [interaction],
};
}
throw Error(
SernError.NotSupportedInteraction + ` There is no autocomplete tag for this option`,
);
}

View File

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

View File

@@ -1,23 +0,0 @@
import type { ChatInputCommandInteraction, Interaction, Message } from 'discord.js';
import { Context } from '../../structures';
import type { Args, SlashOptions } from '../../../types/handler';
/**
* function overloads to create an arguments list for Context
* @param wrap
* @param messageArgs
*/
export function contextArgs(
wrap: Message,
messageArgs?: string[],
): () => [Context, ['text', string[]]];
export function contextArgs(wrap: Interaction): () => [Context, ['slash', SlashOptions]];
export function contextArgs(wrap: Interaction | Message, messageArgs?: string[]) {
const ctx = Context.wrap(wrap as ChatInputCommandInteraction | Message);
const args = ctx.isMessage() ? ['text', messageArgs!] : ['slash', ctx.interaction.options];
return () => [ctx, args] as [Context, Args];
}
export function interactionArg<T extends Interaction>(interaction: T) {
return () => [interaction] as [T];
}

View File

@@ -1,34 +0,0 @@
import type Wrapper from '../structures/wrapper';
import { Subject, type Observable } from 'rxjs';
import type { EventEmitter } from 'events';
import type SernEmitter from '../sernEmitter';
import type { ErrorHandling, Logging, ModuleManager } from '../contracts';
/**
* why did I make this, definitely going to be changed in the future
*/
export abstract class EventsHandler<T> {
protected payloadSubject = new Subject<T>();
protected abstract discordEvent: Observable<unknown>;
protected client: EventEmitter;
protected emitter: SernEmitter;
protected crashHandler: ErrorHandling;
protected logger?: Logging;
protected modules: ModuleManager;
protected constructor({ containerConfig }: Wrapper) {
const [client, emitter, crash, modules, logger] = containerConfig.get(
'@sern/client',
'@sern/emitter',
'@sern/errors',
'@sern/modules',
'@sern/logger',
);
this.logger = logger as Logging | undefined;
this.modules = modules as ModuleManager;
this.client = client as EventEmitter;
this.emitter = emitter as SernEmitter;
this.crashHandler = crash as ErrorHandling;
}
protected abstract init(): void;
protected abstract setState(state: T): void;
}

View File

@@ -0,0 +1,232 @@
import { Interaction, Message } from 'discord.js';
import {
EMPTY,
Observable,
concatMap,
filter,
from,
of,
throwError,
tap,
MonoTypeOperatorFunction,
catchError,
finalize,
} from 'rxjs';
import { ErrorHandling, Logging, ModuleManager, useContainerRaw } from '../../core';
import { SernError } from '../../core/structures/errors';
import { callPlugin, everyPluginOk, filterMap, filterMapTo, handleError } from '../../core/operators';
import { defaultModuleLoader } from '../../core/module-loading';
import { CommandModule, Module, AnyModule } from '../../core/types/modules';
import { contextArgs, createDispatcher, dispatchMessage } from './dispatchers';
import { ObservableInput, pipe, switchMap } from 'rxjs';
import { SernEmitter } from '../../core';
import { errTap } from '../../core/operators';
import * as Files from '../../core/module-loading';
import { Err, Result } from 'ts-results-es';
import { fmt } from './messages';
import { ControlPlugin, VoidResult } from '../../core/types/plugins';
import { ImportPayload, Processed } from '../types';
import { Awaitable } from '../../shared';
import { createId, reconstructId } from '../id';
function createGenericHandler<Source, Narrowed extends Source, Output>(
source: Observable<Source>,
makeModule: (event: Narrowed) => Awaitable<Result<Output, unknown>>,
) {
return (pred: (i: Source) => i is Narrowed) => source.pipe(filter(pred), filterMap(makeModule));
}
/**
*
* Creates an RxJS observable that filters and maps incoming interactions to their respective modules.
* @param i An RxJS observable of interactions.
* @param mg The module manager instance used to retrieve the module path for each interaction.
* @returns A handler to create a RxJS observable of dispatchers that take incoming interactions and execute their corresponding modules.
*/
export function createInteractionHandler<T extends Interaction>(
source: Observable<Interaction>,
mg: ModuleManager,
) {
return createGenericHandler<Interaction, T, ReturnType<typeof createDispatcher>>(
source,
event => {
const fullPath = mg.get(reconstructId(event as unknown as Interaction));
if (!fullPath)
return Err(SernError.UndefinedModule + ' No full path found in module store');
return defaultModuleLoader<Processed<CommandModule>>(fullPath).then(res =>
res.map(payload => createDispatcher({ module: payload.module, event })),
);
},
);
}
export function createMessageHandler(
source: Observable<Message>,
defaultPrefix: string,
mg: ModuleManager,
) {
return createGenericHandler(source, event => {
const [prefix, ...rest] = fmt(event.content, defaultPrefix);
const fullPath = mg.get(`${prefix}_A0`);
if (fullPath === undefined) {
return Err(SernError.UndefinedModule + ' No full path found in module store');
}
return defaultModuleLoader<Processed<CommandModule>>(fullPath).then(result => {
const args = contextArgs(event, rest);
return result.map(payload => 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: createId(module.name, module.type),
});
});
}
export function buildModules<T extends AnyModule>(
input: ObservableInput<string>,
sernEmitter: SernEmitter,
moduleManager: ModuleManager,
) {
return pipe(
switchMap(() => Files.buildModuleStream<T>(input)),
errTap(error => {
sernEmitter.emit('module.register', SernEmitter.failure(undefined, error));
}),
assignDefaults<T>(moduleManager),
);
}
function hasPrefix(prefix: string, content: string) {
const prefixInContent = content.slice(0, prefix.length);
return prefixInContent.localeCompare(prefix, undefined, { sensitivity: 'accent' }) === 0;
}
/**
* Ignores messages from any person / bot except itself
* @param prefix
*/
export function isNonBot(prefix: string) {
return ({ author, content }: Message) => !author.bot && hasPrefix(prefix, content);
}
/**
* Wraps the task in a Result as a try / catch.
* if the task is ok, an event is emitted and the stream becomes empty
* if the task is an error, throw an error down the stream which will be handled by catchError
* @param emitter reference to SernEmitter that will emit a successful execution of module
* @param module the module that will be executed with task
* @param task the deferred execution which will be called
*/
export function executeModule(
emitter: SernEmitter,
{
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));
return EMPTY;
} else {
return throwError(() => SernEmitter.failure(module, result.val));
}
}),
);
}
/**
* A higher order function that
* - creates a stream of {@link VoidResult} { config.createStream }
* - any failures results to { config.onFailure } being called
* - if all results are ok, the stream is converted to { config.onNext }
* emit config.onSuccess Observable
* @param config
* @returns receiver function for flattening a stream of data
*/
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>;
}) {
return (args: Args) => {
const task$ = config.createStream(args);
return task$.pipe(
tap(result => {
result.err && config.onStop?.(args.module);
}),
everyPluginOk,
filterMapTo(() => config.onNext(args)),
);
};
}
/**
* 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>,
Args extends ImportPayload<T>,
>(config: { onStop?: (module: T) => unknown; onNext: (module: Args) => T }) {
return concatMap(
createResultResolver({
createStream: args => from(args.module.plugins).pipe(callPlugin(args)),
...config,
}),
);
}
/**
* Creates an executable task ( execute the command ) if all control plugins are successful
* @param 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), module });
return concatMap(
createResultResolver({
onStop,
createStream: ({ args, module }) => from(module.onEvent).pipe(callPlugin(args)),
onNext,
}),
);
}
export function handleCrash(
errorHandler: ErrorHandling,
logger?: Logging,
) {
return pipe(
catchError(handleError(errorHandler, logger)),
finalize(() => {
logger?.info({ message: 'A stream closed or reached end of lifetime' });
useContainerRaw()?.disposeAll()
.then(() => logger?.info({ message: 'Cleaning container and crashing' }));
})
);
}

View File

@@ -1,137 +0,0 @@
import type { Interaction } from 'discord.js';
import { catchError, concatMap, finalize, fromEvent, map, Observable } from 'rxjs';
import type Wrapper from '../structures/wrapper';
import { EventsHandler } from './eventsHandler';
import { CommandType, SernError, type ModuleStore } from '../structures';
import { match, P } from 'ts-pattern';
import { contextArgs, interactionArg, dispatchAutocomplete, dispatchCommand } from './dispatchers';
import { executeModule, makeModuleExecutor } from './observableHandling';
import type { CommandModule } from '../../types/module';
import { handleError } from '../contracts/errorHandling';
import SernEmitter from '../sernEmitter';
import type { Processed } from '../../types/handler';
import { useContainerRaw } from '../dependencies';
export default class InteractionHandler extends EventsHandler<{
event: Interaction;
module: Processed<CommandModule>;
}> {
protected override discordEvent: Observable<Interaction>;
constructor(wrapper: Wrapper) {
super(wrapper);
this.discordEvent = <Observable<Interaction>>fromEvent(this.client, 'interactionCreate');
this.init();
this.payloadSubject
.pipe(
map(this.createDispatcher),
makeModuleExecutor(module => {
this.emitter.emit(
'module.activate',
SernEmitter.failure(module, SernError.PluginFailure),
);
}),
concatMap(payload => executeModule(this.emitter, payload)),
catchError(handleError(this.crashHandler, this.logger)),
finalize(() => {
this.logger?.info({
message: 'interactionCreate stream closed or reached end of lifetime',
});
useContainerRaw()
?.disposeAll()
.then(() => {
this.logger?.info({ message: 'Cleaning container and crashing' });
});
}),
)
.subscribe();
}
override init() {
const get = (cb: (ms: ModuleStore) => Processed<CommandModule> | undefined) => {
return this.modules.get(cb);
};
/**
* Module retrieval:
* ModuleStores are mapped by Discord API values and modules mapped
* by customId or command name.
*/
this.discordEvent.subscribe({
next: event => {
if (event.isMessageComponent()) {
const module = get(ms =>
ms.InteractionHandlers[event.componentType].get(event.customId),
);
this.setState({ event, module });
} else if (event.isCommand() || event.isAutocomplete()) {
const module = get(
ms =>
/**
* try to fetch from ApplicationCommands, if nothing, try BothCommands
* map. If nothing again,this means a slash command
* exists on the API but not sern
*/
ms.ApplicationCommands[event.commandType].get(event.commandName) ??
ms.BothCommands.get(event.commandName),
);
this.setState({ event, module });
} else if (event.isModalSubmit()) {
const module = get(ms => ms.ModalSubmit.get(event.customId));
this.setState({ event, module });
} else {
throw Error('This interaction is not supported yet');
}
},
error: reason => {
this.emitter.emit('error', SernEmitter.failure(undefined, reason));
},
});
}
protected setState(state: { event: Interaction; module: CommandModule | undefined }): void {
if (state.module === undefined) {
this.emitter.emit(
'warning',
SernEmitter.warning('Found no module for this interaction'),
);
} else {
//if statement above checks already, safe cast
this.payloadSubject.next(
state as { event: Interaction; module: Processed<CommandModule> },
);
}
}
protected createDispatcher({
module,
event,
}: {
event: Interaction;
module: Processed<CommandModule>;
}) {
return (
match(module)
.with({ type: CommandType.Text }, () =>
this.crashHandler.crash(Error(SernError.MismatchEvent)),
)
//P.union = either CommandType.Slash or CommandType.Both
.with({ type: P.union(CommandType.Slash, CommandType.Both) }, module => {
if (event.isAutocomplete()) {
/**
* Autocomplete is a special case that
* must be handled separately, since it's
* too different from regular command modules
*/
return dispatchAutocomplete(module, event);
} else {
return dispatchCommand(module, contextArgs(event));
}
})
/**
* Every other command module takes a one argument parameter, its corresponding interaction
* this makes this usage safe
*/
.otherwise(mod => dispatchCommand(mod, interactionArg(event)))
);
}
}

View File

@@ -0,0 +1,26 @@
import { Interaction } from 'discord.js';
import { concatMap, merge } from 'rxjs';
import { SernError } from '../../core/structures/errors';
import { SernEmitter } from '../../core';
import { sharedObservable } from '../../core/operators';
import { isAutocomplete, isCommand, isMessageComponent, isModal } from '../../core/predicates';
import { createInteractionHandler, executeModule, makeModuleExecutor } from './generic';
import { DependencyList } from '../types';
export function makeInteractionHandler([emitter, , , modules, client]: DependencyList) {
const interactionStream$ = sharedObservable<Interaction>(client, 'interactionCreate');
const handle = createInteractionHandler(interactionStream$, modules);
const interactionHandler$ = merge(
handle(isMessageComponent),
handle(isAutocomplete),
handle(isCommand),
handle(isModal),
);
return interactionHandler$.pipe(
makeModuleExecutor(module => {
emitter.emit('module.activate', SernEmitter.failure(module, SernError.PluginFailure));
}),
concatMap(payload => executeModule(emitter, payload)),
);
}

View File

@@ -1,84 +0,0 @@
import { EventsHandler } from './eventsHandler';
import { catchError, concatMap, EMPTY, finalize, fromEvent, map, Observable, of } from 'rxjs';
import { type Wrapper, type ModuleStore, SernError } from '../structures';
import type { Message } from 'discord.js';
import { executeModule, ignoreNonBot, makeModuleExecutor } from './observableHandling';
import { fmt } from '../utilities/messageHelpers';
import type { CommandModule, Module, TextCommand } from '../../types/module';
import { handleError } from '../contracts/errorHandling';
import { contextArgs, dispatchCommand } from './dispatchers';
import SernEmitter from '../sernEmitter';
import type { Processed } from '../../types/handler';
import { useContainerRaw } from '../dependencies';
export default class MessageHandler extends EventsHandler<{
module: Processed<Module>;
args: unknown[];
}> {
protected discordEvent: Observable<Message>;
public constructor(protected wrapper: Wrapper) {
super(wrapper);
this.discordEvent = <Observable<Message>>fromEvent(this.client, 'messageCreate');
this.init();
this.payloadSubject
.pipe(
makeModuleExecutor(module => {
this.emitter.emit(
'module.activate',
SernEmitter.failure(module, SernError.PluginFailure),
);
}),
concatMap(payload => executeModule(this.emitter, payload)),
catchError(handleError(this.crashHandler, this.logger)),
finalize(() => {
this.logger?.info({
message: 'messageCreate stream closed or reached end of lifetime',
});
useContainerRaw()
?.disposeAll()
.then(() => {
this.logger?.info({ message: 'Cleaning container and crashing' });
});
}),
)
.subscribe();
}
protected init(): void {
if (this.wrapper.defaultPrefix === undefined) return; //for now, just ignore if prefix doesn't exist
const { defaultPrefix } = this.wrapper;
const get = (cb: (ms: ModuleStore) => Processed<CommandModule> | undefined) => {
return this.modules.get(cb);
};
this.discordEvent
.pipe(
ignoreNonBot(this.wrapper.defaultPrefix),
//This concatMap checks if module is undefined, and if it is, do not continue.
// Synonymous to filterMap, but I haven't thought of a generic implementation for filterMap yet
concatMap(message => {
const [prefix, ...rest] = fmt(message, defaultPrefix);
const module = get(
ms => ms.TextCommands.get(prefix) ?? ms.BothCommands.get(prefix),
);
if (module === undefined) {
return EMPTY;
}
const payload = {
args: contextArgs(message, rest),
module,
};
return of(payload);
}),
map(({ args, module }) => dispatchCommand(module as Processed<TextCommand>, args)),
)
.subscribe({
next: value => this.setState(value),
error: reason => this.emitter.emit('error', SernEmitter.failure(reason)),
});
}
protected setState(state: { module: Processed<Module>; args: unknown[] }) {
this.payloadSubject.next(state);
}
}

View File

@@ -0,0 +1,42 @@
import { concatMap, EMPTY } from 'rxjs';
import { SernError } from '../../core/structures/errors';
import type { Message } from 'discord.js';
import { SernEmitter } from '../../core';
import { sharedObservable } from '../../core/operators';
import { createMessageHandler, executeModule, isNonBot, makeModuleExecutor } from './generic';
import { DependencyList } from '../types';
/**
* 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);
}
export function makeMessageHandler(
[emitter, , log, modules, client]: DependencyList,
defaultPrefix: string | undefined,
) {
if (!defaultPrefix) {
log?.debug({ message: 'No prefix found. message handler shutting down' });
return EMPTY;
}
const messageStream$ = sharedObservable<Message>(client, 'messageCreate');
const handler = createMessageHandler(messageStream$, defaultPrefix, modules);
const prefixedMessages$ = handler(isNonBot(defaultPrefix) as (m: Message) => m is Message);
return prefixedMessages$.pipe(
makeModuleExecutor(module => {
emitter.emit('module.activate', SernEmitter.failure(module, SernError.PluginFailure));
}),
concatMap(payload => executeModule(emitter, payload)),
);
}

View File

@@ -1,155 +0,0 @@
import type { Awaitable, Message } from 'discord.js';
import { concatMap, EMPTY, from, Observable, of, pipe, tap, throwError } from 'rxjs';
import type { SernError } from '../structures';
import { Result } from 'ts-results-es';
import type { AnyModule, CommandModule, EventModule, Module } from '../../types/module';
import { _const as i } from '../utilities/functions';
import SernEmitter from '../sernEmitter';
import { callPlugin, everyPluginOk, filterMapTo } from './operators';
import type { Processed } from '../../types/handler';
import type { ControlPlugin, VoidResult } from '../../types/plugin';
/**
* Ignores messages from any person / bot except itself
* @param prefix
*/
export function ignoreNonBot<T extends Message>(prefix: string) {
return (src: Observable<T>) =>
new Observable<T>(subscriber => {
return src.subscribe({
next(m) {
const messageFromHumanAndHasPrefix =
!m.author.bot &&
m.content
.slice(0, prefix.length)
.localeCompare(prefix, undefined, { sensitivity: 'accent' }) === 0;
if (messageFromHumanAndHasPrefix) {
subscriber.next(m);
}
},
});
});
}
/**
* If the current value in Result stream is an error, calls callback.
* This also extracts the Ok value from Result
* @param cb
* @returns Observable<{ module: T; absPath: string }>
*/
export function errTap<T extends AnyModule>(cb: (err: SernError) => void) {
return (src: Observable<Result<{ module: T; absPath: string }, SernError>>) =>
new Observable<{ module: T; absPath: string }>(subscriber => {
return src.subscribe({
next(value) {
if (value.err) {
cb(value.val);
} else {
subscriber.next(value.val);
}
},
});
});
}
/**
* Wraps the task in a Result as a try / catch.
* if the task is ok, an event is emitted and the stream becomes empty
* if the task is an error, throw an error down the stream which will be handled by catchError
* @param emitter reference to SernEmitter that will emit a successful execution of module
* @param module the module that will be executed with task
* @param task the deferred execution which will be called
*/
export function executeModule(
emitter: SernEmitter,
{
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));
return EMPTY;
} else {
return throwError(i(SernEmitter.failure(module, result.val)));
}
}),
);
}
/**
* A higher order function that
* - creates a stream of {@link VoidResult} { config.createStream }
* - any failures results to { config.onFailure } being called
* - if all results are ok, the stream is converted to { config.onSuccess }
* emit config.onSuccess Observable
* @param config
* @returns receiver function for flattening a stream of data
*/
export function createResultResolver<
T extends { execute: (...args: any[]) => any; onEvent: ControlPlugin[] },
Args extends { module: T; [key: string]: unknown },
Output,
>(config: {
onFailure?: (module: T) => unknown;
onSuccess: (args: Args) => Output;
createStream: (args: Args) => Observable<VoidResult>;
}) {
return (args: Args) => {
const task$ = config.createStream(args);
return task$.pipe(
tap(result => {
if (result.err) {
config.onFailure?.(args.module);
}
}),
everyPluginOk(),
filterMapTo(() => config.onSuccess(args)),
);
};
}
/**
* Calls a module's init plugins and checks for Err. If so, call { onFailure } and
* ignore the module
*/
export function scanModule<
T extends Processed<CommandModule | EventModule>,
Args extends { module: T; absPath: string },
>(config: { onFailure?: (module: T) => unknown; onSuccess: (module: Args) => T }) {
return pipe(
concatMap(
createResultResolver({
createStream: args => from(args.module.plugins).pipe(callPlugin(args)),
...config,
}),
),
);
}
/**
* Creates an executable task ( execute the command ) if all control plugins are successful
* @param onFailure emits a failure response to the SernEmitter
*/
export function makeModuleExecutor<
M extends Processed<Module>,
Args extends { module: M; args: unknown[] },
>(onFailure: (m: M) => unknown) {
const onSuccess = ({ args, module }: Args) => ({ task: () => module.execute(...args), module });
return pipe(
concatMap(
createResultResolver({
onFailure,
createStream: ({ args, module }) => from(module.onEvent).pipe(callPlugin(args)),
onSuccess,
}),
),
);
}

View File

@@ -1,70 +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, map, of, OperatorFunction, pipe } from 'rxjs';
import type { AnyModule } from '../../../types/module';
import { nameOrFilename } from '../../utilities/functions';
import type { PluginResult, VoidResult } from '../../../types/plugin';
import { guayin } from '../../plugins';
import { controller } from '../../sern';
/**
* if {src} is true, mapTo V, else ignore
* @param item
*/
export function filterMapTo<V>(item: () => V): OperatorFunction<boolean, V> {
return pipe(concatMap(shouldKeep => (shouldKeep ? of(item()) : EMPTY)));
}
/**
* Calls any plugin with {args}.
* @param args if an array, its spread and plugin called.
*/
export function callPlugin(args: unknown): OperatorFunction<
{
execute: (...args: unknown[]) => PluginResult;
},
VoidResult
> {
return pipe(
concatMap(async plugin => {
const isNewPlugin = Reflect.has(plugin, guayin);
if (isNewPlugin) {
if (Array.isArray(args)) {
return plugin.execute(...args);
}
return plugin.execute(args);
} else {
return plugin.execute(args, controller);
}
}),
);
}
/**
* operator function that fill the defaults for a module
*/
export function defineAllFields<T extends AnyModule>() {
const fillFields = ({ absPath, module }: { absPath: string; module: T }) => ({
absPath,
module: {
name: nameOrFilename(module.name, absPath),
description: module.description ?? '...',
...module,
},
});
return pipe(map(fillFields));
}
/**
* Checks if the stream of results is all ok.
*/
export function everyPluginOk(): OperatorFunction<VoidResult, boolean> {
return pipe(
every(result => result.ok),
defaultIfEmpty(true),
);
}

View File

@@ -0,0 +1,55 @@
import { ObservableInput, fromEvent, take } from 'rxjs';
import { CommandType } from '../../core/structures';
import { SernError } from '../../core/structures/errors';
import { Result } from 'ts-results-es';
import { ModuleManager } from '../../core/contracts';
import { SernEmitter } from '../../core';
import { Processed, DependencyList } from '../types';
import { buildModules, callInitPlugins } from './generic';
import { AnyModule } from '../../core/types/modules';
import * as assert from 'node:assert';
export function startReadyEvent(
[sEmitter, , , moduleManager, client]: DependencyList,
allPaths: ObservableInput<string>,
) {
const ready$ = fromEvent(client!, 'ready').pipe(take(1));
return ready$
.pipe(
buildModules<Processed<AnyModule>>(allPaths, sEmitter, moduleManager),
callInitPlugins({
onStop: module => {
sEmitter.emit(
'module.register',
SernEmitter.failure(module, SernError.PluginFailure),
);
},
onNext: ({ module }) => {
sEmitter.emit('module.register', SernEmitter.success(module));
return module;
},
}),
)
.subscribe(module => {
const result = registerModule(moduleManager, module);
if (result.err) {
throw Error(SernError.InvalidModuleType + ' ' + result.val);
}
});
}
function registerModule<T extends Processed<AnyModule>>(
manager: ModuleManager,
module: T,
): Result<void, void> {
const { id, fullPath } = manager.getMetadata(module);
assert.ok(
module.type > 0 && module.type < 1 << 10,
`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}_A0`, fullPath));
}
return Result.wrap(() => manager.set(id, fullPath));
}

View File

@@ -1,119 +0,0 @@
import { EventsHandler } from './eventsHandler';
import type Wrapper from '../structures/wrapper';
import { concatMap, fromEvent, type Observable, take } from 'rxjs';
import * as Files from '../utilities/readFile';
import { errTap, scanModule } from './observableHandling';
import { CommandType, SernError, type ModuleStore } from '../structures';
import { match } from 'ts-pattern';
import { Result } from 'ts-results-es';
import { ApplicationCommandType, ComponentType } from 'discord.js';
import type { CommandModule } from '../../types/module';
import type { Processed } from '../../types/handler';
import type { ModuleManager } from '../contracts';
import { _const, err, ok } from '../utilities/functions';
import { defineAllFields } from './operators';
import SernEmitter from '../sernEmitter';
export default class ReadyHandler extends EventsHandler<{
module: Processed<CommandModule>;
absPath: string;
}> {
protected discordEvent!: Observable<{ module: CommandModule; absPath: string }>;
constructor(wrapper: Wrapper) {
super(wrapper);
const ready$ = fromEvent(this.client, 'ready').pipe(take(1));
this.discordEvent = ready$.pipe(
concatMap(() =>
Files.buildData<CommandModule>(wrapper.commands).pipe(
errTap(reason => {
this.emitter.emit(
'module.register',
SernEmitter.failure(undefined, reason),
);
}),
),
),
);
this.init();
this.payloadSubject
.pipe(
scanModule({
onFailure: module => {
this.emitter.emit(
'module.register',
SernEmitter.failure(module, SernError.PluginFailure),
);
},
onSuccess: ({ module }) => {
this.emitter.emit('module.register', SernEmitter.success(module));
return module;
},
}),
)
.subscribe(module => {
const res = registerModule(this.modules, module as Processed<CommandModule>);
if (res.err) {
this.crashHandler.crash(Error(SernError.InvalidModuleType));
}
});
}
protected init() {
this.discordEvent.pipe(defineAllFields()).subscribe({
next: value => this.setState(value),
complete: () => this.payloadSubject.unsubscribe(),
});
}
protected setState(state: { absPath: string; module: Processed<CommandModule> }): void {
this.payloadSubject.next(state);
}
}
function registerModule<T extends Processed<CommandModule>>(
manager: ModuleManager,
mod: T,
): Result<void, void> {
const name = mod.name;
const insert = (cb: (ms: ModuleStore) => void) => {
const set = Result.wrap(_const(manager.set(cb)));
return set.ok ? ok() : err();
};
return match(mod as Processed<CommandModule>)
.with({ type: CommandType.Text }, mod => {
mod.alias?.forEach(a => insert(ms => ms.TextCommands.set(a, mod)));
return insert(ms => ms.TextCommands.set(name, mod));
})
.with({ type: CommandType.Slash }, mod =>
insert(ms => ms.ApplicationCommands[ApplicationCommandType.ChatInput].set(name, mod)),
)
.with({ type: CommandType.Both }, mod => {
mod.alias?.forEach(a => insert(ms => ms.TextCommands.set(a, mod)));
return insert(ms => ms.BothCommands.set(name, mod));
})
.with({ type: CommandType.CtxUser }, mod =>
insert(ms => ms.ApplicationCommands[ApplicationCommandType.User].set(name, mod)),
)
.with({ type: CommandType.CtxMsg }, mod =>
insert(ms => ms.ApplicationCommands[ApplicationCommandType.Message].set(name, mod)),
)
.with({ type: CommandType.Button }, mod =>
insert(ms => ms.InteractionHandlers[ComponentType.Button].set(name, mod)),
)
.with({ type: CommandType.StringSelect }, mod =>
insert(ms => ms.InteractionHandlers[ComponentType.StringSelect].set(name, mod)),
)
.with({ type: CommandType.MentionableSelect }, mod =>
insert(ms => ms.InteractionHandlers[ComponentType.MentionableSelect].set(name, mod)),
)
.with({ type: CommandType.ChannelSelect }, mod =>
insert(ms => ms.InteractionHandlers[ComponentType.ChannelSelect].set(name, mod)),
)
.with({ type: CommandType.UserSelect }, mod =>
insert(ms => ms.InteractionHandlers[ComponentType.UserSelect].set(name, mod)),
)
.with({ type: CommandType.RoleSelect }, mod =>
insert(ms => ms.InteractionHandlers[ComponentType.RoleSelect].set(name, mod)),
)
.with({ type: CommandType.Modal }, mod => insert(ms => ms.ModalSubmit.set(name, mod)))
.otherwise(err);
}

View File

@@ -0,0 +1,59 @@
import { ObservableInput, catchError, finalize, map, mergeAll, of } from 'rxjs';
import type { CommandModule, EventModule } from '../../core/types/modules';
import { SernEmitter } from '../../core';
import { EventType } from '../../core/structures';
import { SernError } from '../../core/structures/errors';
import { eventDispatcher } from './dispatchers';
import { buildModules, callInitPlugins } from './generic';
import { handleError } from '../../core/operators';
import { Service, useContainerRaw } from '../../core/ioc';
import { DependencyList, Processed } from '../types';
export function makeEventsHandler(
[emitter, err, log, moduleManager, client]: DependencyList,
allPaths: ObservableInput<string>,
) {
//code smell
const intoDispatcher = (e: Processed<EventModule | CommandModule>) => {
switch (e.type) {
case EventType.Sern:
return eventDispatcher(e, emitter);
case EventType.Discord:
return eventDispatcher(e, client);
case EventType.External:
return eventDispatcher(e, Service(e.emitter));
default:
throw Error(SernError.InvalidModuleType + ' while creating event handler');
}
};
of(null)
.pipe(
buildModules<Processed<EventModule>>(allPaths, emitter, moduleManager),
callInitPlugins({
onStop: module =>
emitter.emit(
'module.register',
SernEmitter.failure(module, SernError.PluginFailure),
),
onNext: ({ module }) => {
emitter.emit('module.register', SernEmitter.success(module));
return module;
},
}),
map(intoDispatcher),
/**
* Where all events are turned on
*/
mergeAll(),
catchError(handleError(err, log)),
finalize(() => {
log?.info({ message: 'an event module reached end of lifetime' });
useContainerRaw()
?.disposeAll()
.then(() => {
log?.info({ message: 'Cleaning container and crashing' });
});
}),
)
.subscribe();
}

View File

@@ -1,72 +0,0 @@
import { catchError, finalize, map, tap } from 'rxjs';
import { buildData } from '../utilities/readFile';
import type { Dependencies, Processed } from '../../types/handler';
import { errTap, scanModule } from './observableHandling';
import type { CommandModule, EventModule } from '../../types/module';
import type { EventEmitter } from 'events';
import SernEmitter from '../sernEmitter';
import { match } from 'ts-pattern';
import type { ErrorHandling, Logging } from '../contracts';
import { SernError, EventType, type Wrapper } from '../structures';
import { eventDispatcher } from './dispatchers';
import { handleError } from '../contracts/errorHandling';
import { defineAllFields } from './operators';
import { useContainerRaw } from '../dependencies';
export function processEvents({ containerConfig, events }: Wrapper) {
const [client, errorHandling, sernEmitter, logger] = containerConfig.get(
'@sern/client',
'@sern/errors',
'@sern/emitter',
'@sern/logger',
) as [EventEmitter, ErrorHandling, SernEmitter, Logging?];
const lazy = (k: string) => containerConfig.get(k as keyof Dependencies)[0];
const eventStream$ = eventObservable$(events!, sernEmitter);
const eventCreation$ = eventStream$.pipe(
defineAllFields(),
scanModule({
onFailure: module => sernEmitter.emit('module.register', SernEmitter.success(module)),
onSuccess: ({ module }) => {
sernEmitter.emit(
'module.register',
SernEmitter.failure(module, SernError.PluginFailure),
);
return module;
},
}),
);
const intoDispatcher = (e: Processed<EventModule | CommandModule>) =>
match(e)
.with({ type: EventType.Sern }, m => eventDispatcher(m, sernEmitter))
.with({ type: EventType.Discord }, m => eventDispatcher(m, client))
.with({ type: EventType.External }, m => eventDispatcher(m, lazy(m.emitter)))
.otherwise(() => errorHandling.crash(Error(SernError.InvalidModuleType)));
eventCreation$
.pipe(
map(intoDispatcher),
/**
* Where all events are turned on
*/
tap(dispatcher => dispatcher.subscribe()),
catchError(handleError(errorHandling, logger)),
finalize(() => {
logger?.info({ message: 'an event module reached end of lifetime' });
useContainerRaw()
?.disposeAll()
.then(() => {
logger?.info({ message: 'Cleaning container and crashing' });
});
}),
)
.subscribe();
}
function eventObservable$(events: string, emitter: SernEmitter) {
return buildData<EventModule>(events).pipe(
errTap(reason => {
emitter.emit('module.register', SernEmitter.failure(undefined, reason));
}),
);
}

50
src/handler/id.ts Normal file
View File

@@ -0,0 +1,50 @@
import { Interaction, InteractionType } from 'discord.js';
import { CommandType, EventType } from '../core';
/**
* 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.
*/
export function reconstructId<T extends Interaction>(event: T) {
switch (event.type) {
case InteractionType.MessageComponent: {
return `${event.customId}_C${event.componentType}`;
}
case InteractionType.ApplicationCommand:
case InteractionType.ApplicationCommandAutocomplete: {
return `${event.commandName}_A${event.commandType}`;
}
//Modal interactions are classified as components for sern
case InteractionType.ModalSubmit: {
return `${event.customId}_C1`;
}
}
}
const appBitField = 0b000000011111;
/*
* 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;
const log = Math.log2(t);
return (appBitField & t) !== 0 ? log : log - 2;
}
/*
* Generates an id based on CommandType.
* A is for any ApplicationCommand. C is for any ComponentCommand
* Then, another number generated by apiType function is appended
*/
function uniqueSuffix(t: CommandType | EventType) {
const am = (appBitField & t) !== 0 ? 'A' : 'C';
return am + apiType(t);
}
export function createId(name: string, type: CommandType | EventType) {
return name+"_"+uniqueSuffix(type)
}

View File

@@ -1,2 +0,0 @@
export { EventArgs, InitArgs, CommandArgs } from './args';
export * from './createPlugin';

View File

@@ -1,131 +1,89 @@
import type Wrapper from './structures/wrapper';
import { processEvents } from './events/userDefinedEventsHandling';
import { CommandType, EventType, PluginType } from './structures/enums';
import type { AnyEventPlugin, ControlPlugin, InitPlugin, Plugin } from '../types/plugin';
import InteractionHandler from './events/interactionHandler';
import ReadyHandler from './events/readyHandler';
import MessageHandler from './events/messageHandler';
import type {
CommandModule,
CommandModuleDefs,
EventModule,
EventModuleDefs,
InputCommand,
InputEvent,
} from '../types/module';
import type { Dependencies, DependencyConfiguration } from '../types/handler';
import { composeRoot, useContainer } from './dependencies/provider';
import type { Logging } from './contracts';
import { err, ok, partition } from './utilities/functions';
import type { Awaitable, ClientEvents } from 'discord.js';
import { makeEventsHandler } from './events/user-defined';
import { makeInteractionHandler } from './events/interactions';
import { startReadyEvent } from './events/ready';
import { makeMessageHandler } from './events/messages';
import { err, ok } from '../core/functions';
import { getFullPathTree } from '../core/module-loading';
import { merge } from 'rxjs';
import { Services } from '../core/ioc';
import { Wrapper } from '../shared';
import { handleCrash } from './events/generic';
/**
*
* @since 1.0.0
* @param wrapper Options to pass into sern.
* Function to start the handler up
* @example
* ```ts title="src/index.ts"
* Sern.init({
* defaultPrefix: '!',
* commands: 'dist/commands',
* events: 'dist/events',
* containerConfig : {
* get: useContainer
* }
* })
* ```
*/
export function init(wrapper: Wrapper) {
const logger = wrapper.containerConfig.get('@sern/logger')[0] as Logging | undefined;
const startTime = performance.now();
const { events } = wrapper;
if (events !== undefined) {
processEvents(wrapper);
const dependencies = useDependencies();
const logger = dependencies[2];
const errorHandler = dependencies[1];
const mode = debugModuleLoading(wrapper.mode ?? process.env.MODE);
if (wrapper.events !== undefined) {
makeEventsHandler(dependencies, getFullPathTree(wrapper.events, mode));
}
new ReadyHandler(wrapper);
new MessageHandler(wrapper);
new InteractionHandler(wrapper);
const endTime = performance.now();
logger?.info({ message: `sern : ${(endTime - startTime).toFixed(2)} ms` });
startReadyEvent(dependencies, 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`,
});
});
const messages$ = makeMessageHandler(dependencies, wrapper.defaultPrefix);
const interactions$ = makeInteractionHandler(dependencies);
merge(messages$, interactions$)
.pipe(handleCrash(errorHandler, logger))
.subscribe();
}
function debugModuleLoading(mode: string | undefined) {
console.info(`Detected mode: "${mode}"`);
if (mode === undefined) {
console.info('No mode found in process.env, assuming DEV');
}
switch (mode) {
case 'PROD':
return false;
case 'DEV':
case undefined:
return true;
default: {
console.warn(mode + ' is not a valid. Should be PROD or DEV');
return false;
}
}
}
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,
};
/**
* The wrapper function to define command modules for sern
* @param mod
*/
export function commandModule(mod: InputCommand): CommandModule {
const [onEvent, plugins] = partition(
mod.plugins ?? [],
el => (el as Plugin).type === PluginType.Control,
);
return {
...mod,
onEvent,
plugins,
} as CommandModule;
}
/**
* The wrapper function to define event modules for sern
* @param mod
*/
export function eventModule(mod: InputEvent): EventModule {
const [onEvent, plugins] = partition(
mod.plugins ?? [],
el => (el as Plugin).type === PluginType.Control,
);
return {
...mod,
onEvent,
plugins,
} as EventModule;
}
/**
* Create event modules from discord.js client events,
* This is an {@link eventModule} for discord events,
* where typings can be very bad.
* @param mod
*/
export function discordEvent<T extends keyof ClientEvents>(mod: {
name: T;
plugins?: AnyEventPlugin[];
execute: (...args: ClientEvents[T]) => Awaitable<unknown>;
}) {
return eventModule({ type: EventType.Discord, ...mod });
}
/**
* @param conf a configuration for creating your project dependencies
*/
export function makeDependencies<T extends Dependencies>(conf: DependencyConfiguration<T>) {
//Until there are more optional dependencies, just check if the logger exists
composeRoot(conf);
return useContainer<T>();
}
/**
* @Experimental
* Will be refactored / changed in future
*/
export abstract class CommandExecutable<Type extends CommandType> {
abstract type: Type;
plugins: InitPlugin[] = [];
onEvent: ControlPlugin[] = [];
abstract execute: CommandModuleDefs[Type]['execute'];
}
/**@Experimental
* Will be refactored in future
*/
export abstract class EventExecutable<Type extends EventType> {
abstract type: Type;
plugins: InitPlugin[] = [];
onEvent: ControlPlugin[] = [];
abstract execute: EventModuleDefs[Type]['execute'];
}

View File

@@ -1,6 +0,0 @@
import Context from './context';
import type Wrapper from './wrapper';
import { ModuleStore } from './moduleStore';
export * from './errors';
export * from './enums';
export { Context, Wrapper, ModuleStore };

View File

@@ -1,26 +0,0 @@
import type { CommandModule } from '../../types/module';
import { ApplicationCommandType, ComponentType } from 'discord.js';
import type { Processed } from '../../types/handler';
/**
* Storing all command modules
* This dependency is usually injected into ModuleManager
*/
export class ModuleStore {
readonly BothCommands = new Map<string, Processed<CommandModule>>();
readonly ApplicationCommands = {
[ApplicationCommandType.User]: new Map<string, Processed<CommandModule>>(),
[ApplicationCommandType.Message]: new Map<string, Processed<CommandModule>>(),
[ApplicationCommandType.ChatInput]: new Map<string, Processed<CommandModule>>(),
};
readonly ModalSubmit = new Map<string, Processed<CommandModule>>();
readonly TextCommands = new Map<string, Processed<CommandModule>>();
readonly InteractionHandlers = {
[ComponentType.Button]: new Map<string, Processed<CommandModule>>(),
[ComponentType.StringSelect]: new Map<string, Processed<CommandModule>>(),
[ComponentType.ChannelSelect]: new Map<string, Processed<CommandModule>>(),
[ComponentType.MentionableSelect]: new Map<string, Processed<CommandModule>>(),
[ComponentType.RoleSelect]: new Map<string, Processed<CommandModule>>(),
[ComponentType.UserSelect]: new Map<string, Processed<CommandModule>>(),
};
}

View File

@@ -1,15 +0,0 @@
import type { Dependencies } from '../../types/handler';
/**
* An object to be passed into Sern#init() function.
* @typedef {object} Wrapper
*/
interface Wrapper {
readonly defaultPrefix?: string;
readonly commands: string;
readonly events?: string;
readonly containerConfig: {
get: (...keys: (keyof Dependencies)[]) => unknown[];
};
}
export default Wrapper;

24
src/handler/types.ts Normal file
View File

@@ -0,0 +1,24 @@
import { ErrorHandling, Logging, ModuleManager, SernEmitter } from '../core';
import { EventEmitter } from 'node:events';
import { Module } from '../core/types/modules';
export type Processed<T> = T & { name: string; description: string };
export type DependencyList = [
SernEmitter,
ErrorHandling,
Logging | undefined,
ModuleManager,
EventEmitter,
];
export interface InitArgs<T extends Processed<Module>> {
module: T;
absPath: string;
}
export interface ImportPayload<T> {
module: T;
absPath: string;
[key: string]: unknown;
}

View File

@@ -1,35 +0,0 @@
import * as Files from './readFile';
import { basename } from 'path';
import { Err, Ok } from 'ts-results-es';
/**
* A function that returns whatever value is provided.
* Warning: this evaluates { @param value }. It does not defer a value.
* @param value
*/
// prettier-ignore
export const _const = <T>(value: T) => () => value;
/**
*
* @param modName
* @param absPath
*/
export function nameOrFilename(modName: string | undefined, absPath: string) {
return modName ?? Files.fmtFileName(basename(absPath));
}
//function wrappers for empty ok / err
export const ok = _const(Ok.EMPTY);
export const err = _const(Err.EMPTY);
export function partition<T, V>(arr: (T & V)[], condition: (e: T & V) => boolean): [T[], V[]] {
const t: T[] = [];
const v: V[] = [];
for (const el of arr) {
if (condition(el)) {
t.push(el as T);
} else {
v.push(el as V);
}
}
return [t, v];
}

View File

@@ -1,15 +0,0 @@
import type { Message } from 'discord.js';
/**
* 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: Message, prefix: string): string[] {
return msg.content.slice(prefix.length).trim().split(/\s+/g);
}

View File

@@ -1,65 +0,0 @@
import { readdirSync, statSync } from 'fs';
import { join } from 'path';
import { type Observable, from, concatAll } from 'rxjs';
import { SernError } from '../structures/errors';
import { type Result, Err, Ok } from 'ts-results-es';
// Courtesy @Townsy45
function readPath(dir: string, arrayOfFiles: string[] = []): string[] {
try {
const files = readdirSync(dir);
for (const file of files) {
if (statSync(dir + '/' + file).isDirectory()) readPath(dir + '/' + file, arrayOfFiles);
else arrayOfFiles.push(join(dir, '/', file));
}
} catch (err) {
throw err;
}
return arrayOfFiles;
}
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 buildData<T>(commandDir: string): Observable<
Result<
{
module: T;
absPath: string;
},
SernError
>
> {
const commands = getCommands(commandDir);
return from(
Promise.all(
commands.map(async absPath => {
let module: T | undefined;
try {
// eslint-disable-next-line @typescript-eslint/no-var-requires
module = require(absPath).default;
} catch {
module = (await import(`file:///` + absPath)).default;
}
if (module === undefined) {
return Err(SernError.UndefinedModule);
}
try {
module = new (module as unknown as new () => T)();
} catch {}
return Ok({ module, absPath });
}),
),
).pipe(concatAll());
}
export function getCommands(dir: string): string[] {
return readPath(join(process.cwd(), dir));
}

View File

@@ -1,18 +1,11 @@
import SernEmitter from './handler/sernEmitter';
export * as Sern from './handler/sern';
export * from './core';
export {
eventModule,
commandModule,
eventModule,
discordEvent,
EventExecutable,
CommandExecutable,
controller,
discordEvent,
} from './handler/sern';
export * as Sern from './handler/sern';
export * from './types/handler';
export * from './types/module';
export * from './types/plugin';
export * from './handler/structures';
export * from './handler/plugins';
export * from './handler/contracts';
export { SernEmitter };
export * from './handler/dependencies';
} from './handler/commands';
export { controller } from './handler/sern';
export type { Wrapper, Args } from './shared';

52
src/shared.ts Normal file
View File

@@ -0,0 +1,52 @@
import type {
CommandInteractionOptionResolver,
InteractionReplyOptions,
MessageReplyOptions,
} from 'discord.js';
import { PayloadType } from './core';
import { AnyModule } from './core/types/modules';
export type ReplyOptions =
| string
| Omit<InteractionReplyOptions, 'fetchReply'>
| MessageReplyOptions;
export type Payload =
| { type: PayloadType.Success; module: AnyModule }
| { type: PayloadType.Failure; module?: AnyModule; reason: string | Error }
| { type: PayloadType.Warning; reason: string };
export interface SernEventsMapping {
'module.register': [Payload];
'module.activate': [Payload];
error: [Payload];
warning: [Payload];
'modulesLoaded': [never?];
}
export type Awaitable<T> = PromiseLike<T> | T;
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[];
};
}
// Thanks to @kelsny
type ParseType<T> = {
[K in keyof T]: T[K] extends unknown ? [k: K, args: T[K]] : never;
}[keyof T];
export type Args = ParseType<{ text: string[]; slash: SlashOptions }>;
export type SlashOptions = Omit<CommandInteractionOptionResolver, 'getMessage' | 'getFocused'>;

View File

@@ -1,69 +0,0 @@
import type { CommandInteractionOptionResolver } from 'discord.js';
import type { PayloadType } from '../handler/structures/enums';
import type { InteractionReplyOptions, MessageReplyOptions } from 'discord.js';
import type { EventEmitter } from 'events';
import type { CommandModule, EventModule, AnyModule } from './module';
import type { UnpackFunction } from 'iti';
import type { ErrorHandling, Logging, ModuleManager } from '../handler/contracts';
import type { ModuleStore } from '../handler/structures/moduleStore';
import type SernEmitter from '../handler/sernEmitter';
import type { Container } from 'iti';
// Thanks to @kelsny
export type ParseType<T> = {
[K in keyof T]: T[K] extends unknown ? [k: K, args: T[K]] : never;
}[keyof T];
export type Args = ParseType<{ text: string[]; slash: SlashOptions }>;
export type SlashOptions = Omit<CommandInteractionOptionResolver, 'getMessage' | 'getFocused'>;
/**
* After modules are transformed, name and description are given default values if none
* are provided to Module. This type represents that transformation
*/
export type AnyDefinedModule = Processed<CommandModule | EventModule>;
export type Payload =
| { type: PayloadType.Success; module: AnyModule }
| { type: PayloadType.Failure; module?: AnyModule; reason: string | Error }
| { type: PayloadType.Warning; reason: string };
export type SernEventsMapping = {
'module.register': [Payload];
'module.activate': [Payload];
error: [Payload];
warning: [Payload];
};
export type LogPayload<T = unknown> = { message: T };
export type Singleton<T> = () => T;
export type Transient<T> = () => () => T;
export interface Dependencies {
'@sern/client': Singleton<EventEmitter>;
'@sern/logger'?: Singleton<Logging>;
'@sern/emitter': Singleton<SernEmitter>;
'@sern/store': Singleton<ModuleStore>;
'@sern/modules': Singleton<ModuleManager>;
'@sern/errors': Singleton<ErrorHandling>;
}
export type ReplyOptions =
| string
| Omit<InteractionReplyOptions, 'fetchReply'>
| MessageReplyOptions;
//prettier-ignore
export type MapDeps<Deps extends Dependencies, T extends readonly unknown[]> = T extends [
infer First extends keyof Deps,
...infer Rest extends readonly unknown[],
]
? [
UnpackFunction<Deps[First]>,
...(MapDeps<Deps, Rest> extends [never] ? [] : MapDeps<Deps, Rest>),
]
: [never];
//Basically, '@sern/client' | '@sern/store' | '@sern/modules' | '@sern/error' | '@sern/emitter' will be provided defaults, and you can exclude the rest
export type OptionalDependencies = '@sern/logger';
export type Processed<T> = T & { name: string; description: string };
export type Deprecated<Message extends string> = [never, Message];
export interface DependencyConfiguration<T extends Dependencies> {
exclude?: Set<OptionalDependencies>;
build: (root: Container<Omit<Dependencies, '@sern/client'>, {}>) => Container<T, {}>;
}

View File

@@ -1,74 +0,0 @@
/*
* Plugins can be inserted on all commands and are emitted
*
* 1. On ready event, where all commands are loaded.
* 2. On corresponding observable (when command triggers)
*
* The goal of plugins is to organize commands and
* provide extensions to repetitive patterns
* examples include refreshing modules,
* categorizing commands, cool-downs, permissions, etc.
* Plugins are reminiscent of middleware in express.
*/
import type { Awaitable } from 'discord.js';
import type { Err, Ok, Result } from 'ts-results-es';
import type { PluginType } from '../handler/structures/enums';
import type { CommandModule, EventModule } from './module';
import type { CommandArgs, InitArgs } from '../handler/plugins';
import type { Deprecated, Processed } from './handler';
import type { CommandType } from '../handler/structures/enums';
export type PluginResult = Awaitable<VoidResult>;
export type VoidResult = Result<void, void>;
export interface Controller {
next: () => Ok<void>;
stop: () => Err<void>;
}
export interface Plugin<Args extends any[] = any[]> {
type: PluginType;
execute: (...args: Args) => PluginResult;
}
export interface InitPlugin<Args extends any[] = any[]> {
type: PluginType.Init;
execute: (...args: Args) => PluginResult;
}
export interface ControlPlugin<Args extends any[] = any[]> {
type: PluginType.Control;
execute: (...args: Args) => PluginResult;
}
export type AnyCommandPlugin = ControlPlugin | InitPlugin<[InitArgs<Processed<CommandModule>>]>;
export type AnyEventPlugin = ControlPlugin | InitPlugin<[InitArgs<Processed<EventModule>>]>;
/**
* @deprecated
* Use the newer helper functions and import { controller } from '@sern/handler'
*/
export interface CommandPlugin<T extends CommandType = CommandType> {
name?: string;
description?: string;
type: PluginType.Command;
execute: (
m: InitArgs<Processed<CommandModule>>,
controller?: Deprecated<'Please import controller instead'>,
) => PluginResult;
}
/**
* @deprecated
* Use the newer helper functions
*/
export interface EventPlugin<T extends CommandType> {
name?: string;
description?: string;
type: PluginType.Event;
execute: (args: CommandArgs<T, PluginType.Event>, controller?: Controller) => PluginResult;
}
export type DiscordEmitterPlugin = Deprecated<'Please view alternatives: '>;
export type ExternalEmitterPlugin = Deprecated<'Please view alternatives: '>;
export type SernEmitterPlugin = Deprecated<'Please view alternatives: '>;
export type AutocompletePlugin = Deprecated<'Please view alternatives: '>;
export type SernEventPlugin = Deprecated<'Please view alternatives: '>;
export type ExternalEventPlugin = Deprecated<'Please view alternatives: '>;
export type DiscordEventPlugin = Deprecated<'Please view alternatives: '>;

View File

@@ -0,0 +1,14 @@
import { assertType, describe, it, vi } from "vitest";
import * as DefaultContracts from '../../src/core/structures/services'
import * as Contracts from '../../src/core/contracts/index.js'
import { ModuleStore } from "../../src/core";
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())
})
})

View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest'
import { CommandControlPlugin, CommandInitPlugin, EventControlPlugin, EventInitPlugin } from '../../src/core/create-plugins'
import { PluginType, controller } from '../../src/index'
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)
expect(pl2.execute).an('function')
})
it('should make proper init plugins', () => {
const pl = EventInitPlugin(() => controller.next())
expect(pl)
.to.have.all.keys(['type', 'execute'])
expect(pl.type).toBe(PluginType.Init)
expect(pl.execute).an('function')
const pl2 = CommandInitPlugin(() => controller.next())
expect(pl2)
.to.have.all.keys(['type', 'execute'])
expect(pl2.type).toBe(PluginType.Init)
expect(pl2.execute).an('function')
})
})

208
test/core/functions.test.ts Normal file
View File

@@ -0,0 +1,208 @@
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
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()
}
}
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
},
ModalSubmitInteraction,
ButtonInteraction,
AutocompleteInteraction
};
})
describe('functions', () => {
afterEach(() => { vi.clearAllMocks() })
function createRandomPlugins(len: number) {
const random = () => Math.floor(Math.random()*2)+1; // 1 or 2, plugin enum
return Array.from({ length: len }, () => ({ type: random(), execute: () => random() === 1 ? controller.next():controller.stop() }))
}
function createRandomChoice() {
return {
type: faker.number.int({ min: 1, max: 11}),
name: faker.word.noun(),
description: faker.word.adjective(),
}
}
it('should partition plugins correctly', () => {
const plugins = createRandomPlugins(100);
const [ onEvent, init ] = partitionPlugins(plugins)
for(const el of onEvent)
expect(el.type).to.equal(PluginType.Control)
for(const el of init)
expect(el.type).to.equal(PluginType.Init)
})
it('should tree search options tree depth 1', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('autocomplete');
const options : SernOptionsData[] = [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'autocomplete',
description: 'here',
autocomplete: true,
command : { onEvent: [], execute:(a) => {} }
}
];
autocmpInteraction.options.getFocused.mockReturnValue(
{
name: 'autocomplete',
value: faker.string.alpha(),
focused: true
},
);
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('autocomplete');
expect(result.command).to.be.not.undefined;
}),
it('should tree search depth 2', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('nested');
const options : SernOptionsData[] = [
{
type: ApplicationCommandOptionType.Subcommand,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute:() => {}
}
}
]
}
];
autocmpInteraction.options.getFocused.mockReturnValue(
{
name: 'nested',
value: faker.string.alpha(),
focused: true
}
);
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
})
it('should tree search depth n > 2', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('nested');
const options : SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute:() => {}
}
}
]
}]
},
];
autocmpInteraction.options.getFocused.mockReturnValue(
{
name: 'nested',
value: faker.string.alpha(),
focused: true
}
);
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
})
})

52
test/core/ioc.test.ts Normal file
View File

@@ -0,0 +1,52 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { CoreContainer } from '../../src/core/structures/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,80 @@
import { SpyInstance, afterAll, beforeEach, describe, expect, it, vi } from "vitest";
import { CoreContainer } from '../../src/core/structures/container'
import { DefaultLogging } from "../../src/core";
import { faker } from '@faker-js/faker'
import { commandModule } from "../../src";
import { createId } from '../../src/handler/id'
import { CommandMeta } from "../../src/core/types/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: createId(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(createId(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' })
})
})

View File

@@ -6,13 +6,14 @@
"noImplicitAny": true,
"experimentalDecorators": true,
"strictNullChecks": true,
"importsNotUsedAsValues": "error",
"moduleResolution": "node",
"skipLibCheck": true,
"declaration": true,
"preserveSymlinks": true,
"allowSyntheticDefaultImports": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"isolatedModules": true
},
"exclude": ["node_modules", "dist"],
"include": ["src"]
"include": ["./src", "./src/**/*.d.ts"]
}

3
tsconfig.json Normal file
View File

@@ -0,0 +1,3 @@
{
"extends": "./tsconfig-esm.json"
}

View File

@@ -1,32 +1,43 @@
import { defineConfig } from 'tsup';
import { writeFile } from 'fs/promises';
import ifdefPlugin from 'esbuild-ifdef';
const shared = {
entry: ['src/index.ts'],
external: ['discord.js'],
external: ['discord.js', 'iti'],
platform: 'node',
clean: true,
sourcemap: false,
minify: true
treeshake: {
moduleSideEffects: false,
correctVarValueBeforeDeclaration: true, //need this to treeshake esm discord.js empty import
annotations: true,
},
dts: false,
};
export default defineConfig([
{
format: 'esm',
target: 'node16',
target: 'node18',
tsconfig: './tsconfig-esm.json',
outDir: './dist/esm',
external: ['discord.js'],
treeshake: true,
splitting: true,
esbuildPlugins: [ifdefPlugin({ variables: { MODE: 'esm' }, verbose: true })],
outExtension() {
return {
js: '.mjs',
};
},
async onSuccess() {
console.log('writing json esm');
await writeFile('./dist/esm/package.json', JSON.stringify({ type: 'module' }));
},
...shared,
},
{
format: 'cjs',
esbuildPlugins: [ifdefPlugin({ variables: { MODE: 'cjs' }, verbose: true })],
splitting: false,
external: ['discord.js'],
target: 'node16',
target: 'node18',
tsconfig: './tsconfig-cjs.json',
outDir: './dist/cjs',
outExtension() {
@@ -34,6 +45,17 @@ export default defineConfig([
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',
},
]);

3755
yarn.lock Normal file

File diff suppressed because it is too large Load Diff