Compare commits

..

33 Commits

Author SHA1 Message Date
Jacob Nguyen
4722849f56 Merge branch 'main' into fix/dispose 2025-03-10 00:49:21 -05:00
renovate[bot]
513ac8edf4 chore(deps): lock file maintenance (#391)
Some checks failed
NPM / Publish / test-and-publish (push) Has been cancelled
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-03-10 00:46:28 -05:00
Jacob Nguyen
3673263f3a s 2025-03-10 00:41:24 -05:00
Jacob Nguyen
24ec4d6ad6 testbot 2025-03-10 00:39:37 -05:00
Jacob Nguyen
27d13f1ea5 Merge branch 'main' into fix/dispose 2025-03-10 00:15:34 -05:00
github-actions[bot]
81a0180d05 chore(main): release 4.2.4 (#396)
Some checks failed
Continuous Delivery / Publishing Dev (push) Has been cancelled
NPM / Publish / test-and-publish (push) Has been cancelled
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-05 21:24:41 -06:00
Jacob Nguyen
89d7409536 fix: flat autocomplete (#395)
* first

* fix
2025-03-05 21:22:54 -06:00
github-actions[bot]
aa802f761e chore(main): release 4.2.3 (#394)
Some checks failed
Continuous Delivery / Publishing Dev (push) Has been cancelled
NPM / Publish / test-and-publish (push) Has been cancelled
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-03-03 21:48:03 -06:00
Jacob Nguyen
2414992b73 fix: autocomplete sdt.module not present (#393) 2025-03-03 21:45:18 -06:00
kingomes
70c6236802 Update README.md (#392)
Some checks failed
NPM / Publish / test-and-publish (push) Has been cancelled
2025-02-15 11:09:34 -06:00
Jacob Nguyen
ed5bb20c60 asdf 2025-02-07 02:01:40 -06:00
Jacob Nguyen
0d77878dfb Merge branch 'main' into fix/dispose 2025-02-07 01:56:30 -06:00
Jacob Nguyen
8ac7b60b59 Delete yarn.lock 2025-02-07 01:56:10 -06:00
Jacob Nguyen
1f25aa64b9 plock
Some checks failed
Continuous Delivery / Publishing Dev (push) Has been cancelled
NPM / Publish / test-and-publish (push) Has been cancelled
2025-02-07 01:45:57 -06:00
Jacob Nguyen
7cddee30aa lock 2025-02-07 01:44:49 -06:00
renovate[bot]
e7286eee9f chore(deps): lock file maintenance (#331)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-02-07 01:43:27 -06:00
Jacob Nguyen
a67450328e fuckyarn 2025-02-07 00:50:17 -06:00
jacoobes
926d531863 sdfds 2025-02-03 18:34:26 -06:00
jacoobes
ef3d3d71a0 disposalfixe 2025-02-03 18:33:56 -06:00
Jacob Nguyen
47401f46a3 Update README.md
Some checks failed
NPM / Publish / test-and-publish (push) Has been cancelled
2025-02-03 11:47:49 -06:00
github-actions[bot]
1059065980 chore(main): release 4.2.2 (#388)
Some checks failed
NPM / Publish / test-and-publish (push) Waiting to run
Continuous Delivery / Publishing Dev (push) Has been cancelled
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-02-02 19:40:19 -06:00
Jacob Nguyen
974c30fa6c fix: faster autocomplete lookup (#387)
* fix:faster-autocmp

* fixinitializing

* fix

* fixonwindows

* unconsole
2025-02-02 19:37:59 -06:00
Jacob Nguyen
3a569726d8 docs: Sincetagsandmoredocumentation (#385)
Some checks failed
Continuous Delivery / Publishing Dev (push) Has been cancelled
NPM / Publish / test-and-publish (push) Has been cancelled
* commit

* docs
2025-01-27 18:29:20 -06:00
Ararou
1b7f2a49a8 change formatting of example projects (#384)
Some checks failed
NPM / Publish / test-and-publish (push) Has been cancelled
2025-01-25 22:26:31 -06:00
github-actions[bot]
97fa2a2d78 chore(main): release 4.2.1 (#383)
Some checks failed
Continuous Delivery / Publishing Dev (push) Has been cancelled
NPM / Publish / test-and-publish (push) Has been cancelled
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-01-24 16:38:33 -06:00
Jacob Nguyen
a52ad270d8 fix: context-interactions error (#382)
Some checks are pending
Continuous Delivery / Publishing Dev (push) Waiting to run
NPM / Publish / test-and-publish (push) Waiting to run
2025-01-24 10:37:51 -06:00
github-actions[bot]
3f703c17b8 chore(main): release 4.2.0 (#380)
Some checks failed
Continuous Delivery / Publishing Dev (push) Has been cancelled
NPM / Publish / test-and-publish (push) Has been cancelled
Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2025-01-18 12:00:13 -06:00
Jacob Nguyen
f9e7eaf92d feat: 4.2.0 load multiple directories & handleModuleErrors (#378)
* error-handling-draft

* feat: array based module loading (#379)

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

* Update utility.ts

* Update sern.ts

* describesemanticsbetter

---------

Co-authored-by: Duro <davidwright13503@gmail.com>
2025-01-18 11:47:51 -06:00
github-actions[bot]
52e145600d chore(main): release 4.1.1 (#377)
Some checks failed
Continuous Delivery / Publishing Dev (push) Has been cancelled
NPM / Publish / test-and-publish (push) Has been cancelled
* chore(main): release 4.1.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>
2025-01-13 18:59:14 -06:00
Jacob Nguyen
59d08ef207 fix: remove rxjs (#376)
Some checks are pending
Continuous Delivery / Publishing Dev (push) Waiting to run
NPM / Publish / test-and-publish (push) Waiting to run
* firstcommit

* removerxjs

* document-task

* documentation+clean

* fixregres

* fix+regress

* fix+regres+errorhandling
2025-01-13 10:33:53 -06:00
Jacob Nguyen
7deb79e907 Delete .github/workflows/continuous-integration.yml
Some checks failed
NPM / Publish / test-and-publish (push) Has been cancelled
2025-01-11 13:59:20 -06:00
Jacob Nguyen
f2d4b5bda1 cleanup-tests
Some checks failed
NPM / Publish / test-and-publish (push) Has been cancelled
2025-01-07 17:33:33 -06:00
Glitch
a575b3ed74 update license year (#375)
Some checks failed
NPM / Publish / test-and-publish (push) Waiting to run
Continuous Delivery / Publishing Dev (push) Has been cancelled
2025-01-06 17:16:31 -06:00
92 changed files with 7765 additions and 5195 deletions

View File

@@ -1,50 +0,0 @@
name: Continuous Integration
on:
# Trigger the workflow on push or pull request or custom
push:
branches: [main]
paths:
- '*.ts'
pull_request_target:
branches:
main
paths:
- '*ts'
workflow_dispatch:
jobs:
Prettier:
name: Run Prettier
runs-on: ubuntu-latest
steps:
- name: Check out Git repository
uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- name: Set up Node.js
uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3
with:
node-version: 17
- name: Install pnpm
run: npm i -g yarn
# Prettier must be in `package.json`
- name: Install Node.js dependencies
run: yarn --immutable
- name: Run Prettier
run: yarn pretty
- name: Create Pull Request
id: cpr
uses: peter-evans/create-pull-request@38e0b6e68b4c852a5500a94740f0e535e0d7ba54 # v4
with:
commit-message: "style: pretty please"
branch: prettier
delete-branch: true
branch-suffix: short-commit-hash
title: "style: pretty please"
body: "pretty pretty prettier"
reviewers: EvolutionX-10

View File

@@ -13,9 +13,9 @@ jobs:
- uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3
- uses: actions/setup-node@1a4442cacd436585916779262731d5b162bc6ec7 # v3
with:
node-version: 17
- run: yarn --immutable
- run: yarn build:prod
node-version: 18
- run: npm i
- run: npm run build:prod
- uses: JS-DevTools/npm-publish@0f451a94170d1699fd50710966d48fb26194d939 # v1
with:
token: ${{ secrets.NPM_TOKEN }}

View File

@@ -24,6 +24,5 @@ jobs:
with:
node-version: ${{ matrix.node-version }}
cache: 'npm'
- run: npm install -g yarn
- run: yarn install
- run: yarn test
- run: npm install
- run: npm run test

File diff suppressed because one or more lines are too long

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@
<div align="center" styles="margin-top: 10px">
<img src="https://img.shields.io/badge/open-source-brightgreen" />
<img src="https://img.shields.io/badge/built_with-sern-pink?labelColor=%230C3478&color=%23ed5087&link=https%3A%2F%2Fsern.dev"/>
<a href="https://www.npmjs.com/package/@sern/handler"><img src="https://img.shields.io/npm/v/@sern/handler?maxAge=3600" alt="NPM version" /></a>
<a href="https://www.npmjs.com/package/@sern/handler"><img src="https://img.shields.io/npm/dt/@sern/handler?maxAge=3600" alt="NPM downloads" /></a>
<a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/license-MIT-brightgreen" alt="License MIT" /></a>
@@ -19,7 +20,7 @@
- Lightweight. Does a lot while being small.
- Latest features. Support for discord.js v14 and all of its interactions.
- Start quickly. Plug and play or customize to your liking.
- works with [bun](https://bun.sh/) and [node](https://nodejs.org/en) out the box!
- Works with [bun](https://bun.sh/) and [node](https://nodejs.org/en) out the box!
- Use it with TypeScript or JavaScript. CommonJS and ESM supported.
- Active and growing community, always here to help. [Join us](https://sern.dev/discord)
- Unleash its full potential with a powerful CLI and awesome plugins.
@@ -43,20 +44,29 @@ export default commandModule({
```
</details>
# Show off your sern Discord Bot!
## Badge
- Copy this and add it to your [README.md](https://img.shields.io/badge/built_with-sern-pink?labelColor=%230C3478&color=%23ed5087&link=https%3A%2F%2Fsern.dev)
<img src="https://img.shields.io/badge/built_with-sern-pink?labelColor=%230C3478&color=%23ed5087&link=https%3A%2F%2Fsern.dev">
## 🤖 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.
- [Murayama](https://github.com/murayamabot/murayama), :pepega:
- [Protector](https://github.com/GlitchApotamus/Protector), Just a simple bot to help enhance a private minecraft server.
- [SmokinWeed 💨](https://github.com/Peter-MJ-Parker/sern-bud), A fun bot for a small - but growing - server.
- [Man Nomic](https://github.com/jacoobes/man-nomic), A simple information bot to provide information to the nomic-ai discord community.
- [Linear-Discord](https://github.com/sern-handler/linear-discord) Display and manage a linear dashboard.
- [Community Bot](https://github.com/sern-handler/sern-community) - The community bot for our [Discord server](https://sern.dev/discord).
- [Vinci](https://github.com/SrIzan10/vinci) - The bot for Mara Turing.
- [Bask](https://github.com/baskbotml/bask) - Listen to your favorite artists on Discord.
- [Murayama](https://github.com/murayamabot/murayama) - :pepega:
- [Protector](https://github.com/GlitchApotamus/Protector) - Just a simple bot to help enhance a private Minecraft server.
- [SmokinWeed 💨](https://github.com/Peter-MJ-Parker/sern-bud) - A fun bot for a small, but growing server.
- [Man Nomic](https://github.com/jacoobes/man-nomic) - A simple information bot to provide information to the nomic-ai Discord community.
- [Linear-Discord](https://github.com/sern-handler/linear-discord) - Display and manage a linear dashboard.
- [ZenithBot](https://github.com/CodeCraftersHaven/ZenithBot) - A versatile bot coded in TypeScript, designed to enhance server management and user interaction through its robust features.
## 💻 CLI
It is **highly encouraged** to use the [command line interface](https://github.com/sern-handler/cli) for your project. Don't forget to view it.
## 🔗 Links
- [Official Documentation and Guide](https://sern.dev)

4
bot/.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
/node_modules
/dist
.env
.sern

6
bot/README.md Normal file
View File

@@ -0,0 +1,6 @@
# Test bot
## add .env
DISCORD_TOKEN=<token>
NODE_ENV=<production|development>

View File

@@ -0,0 +1,12 @@
{
"command/ping": {
"name": "ping",
"description": "yeth",
"options": {
"asdfs": {
"name": "shidenglish",
"description": "yeah"
}
}
}
}

View File

@@ -0,0 +1,12 @@
{
"command/ping": {
"name": "ping",
"description": "hola",
"options": {
"asdfs": {
"name": "shidspnaol",
"description": "si"
}
}
}
}

1
bot/assets/test.txt Normal file
View File

@@ -0,0 +1 @@
{ "sdfasdfas": "asdf" }

418
bot/package-lock.json generated Normal file
View File

@@ -0,0 +1,418 @@
{
"name": "plugtest",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "plugtest",
"version": "1.0.0",
"license": "UNLICENSED",
"dependencies": {
"@sern/handler": "file:../",
"@sern/localizer": "^1.1.3",
"@sern/publisher": "^1.1.2",
"discord.js": "^14.15.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/node": "^17.0.25",
"typescript": "latest"
}
},
"..": {
"name": "@sern/handler",
"version": "4.2.4",
"license": "MIT",
"dependencies": {
"@sern/ioc": "^1.1.2",
"callsites": "^3.1.0",
"cron": "^3.1.7",
"deepmerge": "^4.3.1"
},
"devDependencies": {
"@faker-js/faker": "^8.0.1",
"@types/node": "^20.0.0",
"@types/node-cron": "^3.0.11",
"@typescript-eslint/eslint-plugin": "5.58.0",
"@typescript-eslint/parser": "5.59.1",
"discord.js": "^14.14.1",
"eslint": "8.39.0",
"typescript": "5.0.2",
"vitest": "^1.6.0"
},
"engines": {
"node": ">= 20.0.x"
}
},
"../../tools/packages/builder": {
"name": "@sern/builder",
"version": "1.0.0-rc1",
"extraneous": true,
"license": "ISC",
"dependencies": {
"discord-api-types": "latest"
},
"devDependencies": {
"@types/node": "^20.1.0"
}
},
"../handler": {
"name": "@sern/handler",
"version": "4.2.3",
"extraneous": true,
"license": "MIT",
"dependencies": {
"@sern/ioc": "^1.1.2",
"callsites": "^3.1.0",
"cron": "^3.1.7",
"deepmerge": "^4.3.1"
},
"devDependencies": {
"@faker-js/faker": "^8.0.1",
"@types/node": "^20.0.0",
"@types/node-cron": "^3.0.11",
"@typescript-eslint/eslint-plugin": "5.58.0",
"@typescript-eslint/parser": "5.59.1",
"discord.js": "^14.14.1",
"eslint": "8.39.0",
"typescript": "5.0.2",
"vitest": "^1.6.0"
},
"engines": {
"node": ">= 20.0.x"
}
},
"../tools/packages/builder": {
"name": "@sern/builder",
"version": "1.0.0-rc1",
"extraneous": true,
"license": "ISC",
"dependencies": {
"discord-api-types": "latest"
},
"devDependencies": {
"@types/node": "^20.1.0"
}
},
"../tools/packages/localizer": {
"name": "@sern/localizer",
"version": "1.1.1",
"extraneous": true,
"license": "ISC",
"dependencies": {
"shrimple-locales": "^0.2.1"
},
"devDependencies": {
"@sern/handler": "^4.0.0",
"discord.js": "^14.15.3",
"vitest": "^1.2.2"
}
},
"node_modules/@discordjs/builders": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/@discordjs/builders/-/builders-1.10.0.tgz",
"integrity": "sha512-ikVZsZP+3shmVJ5S1oM+7SveUCK3L9fTyfA8aJ7uD9cNQlTqF+3Irbk2Y22KXTb3C3RNUahRkSInClJMkHrINg==",
"dependencies": {
"@discordjs/formatters": "^0.6.0",
"@discordjs/util": "^1.1.1",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.37.114",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/collection": {
"version": "1.5.3",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-1.5.3.tgz",
"integrity": "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ==",
"engines": {
"node": ">=16.11.0"
}
},
"node_modules/@discordjs/formatters": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@discordjs/formatters/-/formatters-0.6.0.tgz",
"integrity": "sha512-YIruKw4UILt/ivO4uISmrGq2GdMY6EkoTtD0oS0GvkJFRZbTSdPhzYiUILbJ/QslsvC9H9nTgGgnarnIl4jMfw==",
"dependencies": {
"discord-api-types": "^0.37.114"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest": {
"version": "2.4.2",
"resolved": "https://registry.npmjs.org/@discordjs/rest/-/rest-2.4.2.tgz",
"integrity": "sha512-9bOvXYLQd5IBg/kKGuEFq3cstVxAMJ6wMxO2U3wjrgO+lHv8oNCT+BBRpuzVQh7BoXKvk/gpajceGvQUiRoJ8g==",
"dependencies": {
"@discordjs/collection": "^2.1.1",
"@discordjs/util": "^1.1.1",
"@sapphire/async-queue": "^1.5.3",
"@sapphire/snowflake": "^3.5.3",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.37.114",
"magic-bytes.js": "^1.10.0",
"tslib": "^2.6.3",
"undici": "6.19.8"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/rest/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/util": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/util/-/util-1.1.1.tgz",
"integrity": "sha512-eddz6UnOBEB1oITPinyrB2Pttej49M9FZQY8NxgEvc3tq6ZICZ19m70RsmzRdDHk80O9NoYN/25AqJl8vPVf/g==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/@discordjs/ws/-/ws-1.2.0.tgz",
"integrity": "sha512-QH5CAFe3wHDiedbO+EI3OOiyipwWd+Q6BdoFZUw/Wf2fw5Cv2fgU/9UEtJRmJa9RecI+TAhdGPadMaEIur5yJg==",
"dependencies": {
"@discordjs/collection": "^2.1.0",
"@discordjs/rest": "^2.4.1",
"@discordjs/util": "^1.1.0",
"@sapphire/async-queue": "^1.5.2",
"@types/ws": "^8.5.10",
"@vladfrangu/async_event_emitter": "^2.2.4",
"discord-api-types": "^0.37.114",
"tslib": "^2.6.2",
"ws": "^8.17.0"
},
"engines": {
"node": ">=16.11.0"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@discordjs/ws/node_modules/@discordjs/collection": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/@discordjs/collection/-/collection-2.1.1.tgz",
"integrity": "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg==",
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/@sapphire/async-queue": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@sapphire/async-queue/-/async-queue-1.5.5.tgz",
"integrity": "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg==",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sapphire/shapeshift": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/@sapphire/shapeshift/-/shapeshift-4.0.0.tgz",
"integrity": "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg==",
"dependencies": {
"fast-deep-equal": "^3.1.3",
"lodash": "^4.17.21"
},
"engines": {
"node": ">=v16"
}
},
"node_modules/@sapphire/snowflake": {
"version": "3.5.3",
"resolved": "https://registry.npmjs.org/@sapphire/snowflake/-/snowflake-3.5.3.tgz",
"integrity": "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ==",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/@sern/handler": {
"resolved": "..",
"link": true
},
"node_modules/@sern/localizer": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/@sern/localizer/-/localizer-1.1.3.tgz",
"integrity": "sha512-hTn0DtiAzIWSuokqMsvnVuFqU+P776p/Yv5etlrq+CWDgw332Hwuj3geyqN1C0yEjwF+ceyXJE/kGu2/inkEyg==",
"dependencies": {
"shrimple-locales": "^0.2.1"
}
},
"node_modules/@sern/publisher": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@sern/publisher/-/publisher-1.1.2.tgz",
"integrity": "sha512-1zh99JZykKUhqHhE75ZXfiLsBtf1WI+NnDCojv8UlpnGBEyzO8xyI1X7PNf6cPKRs4W9XqY3PqTJ+hrqzIsMkg=="
},
"node_modules/@types/node": {
"version": "17.0.45",
"resolved": "https://registry.npmjs.org/@types/node/-/node-17.0.45.tgz",
"integrity": "sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw=="
},
"node_modules/@types/ws": {
"version": "8.5.14",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.5.14.tgz",
"integrity": "sha512-bd/YFLW+URhBzMXurx7lWByOu+xzU9+kb3RboOteXYDfW+tr+JZa99OyNmPINEGB/ahzKrEuc8rcv4gnpJmxTw==",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@vladfrangu/async_event_emitter": {
"version": "2.4.6",
"resolved": "https://registry.npmjs.org/@vladfrangu/async_event_emitter/-/async_event_emitter-2.4.6.tgz",
"integrity": "sha512-RaI5qZo6D2CVS6sTHFKg1v5Ohq/+Bo2LZ5gzUEwZ/WkHhwtGTCB/sVLw8ijOkAUxasZ+WshN/Rzj4ywsABJ5ZA==",
"engines": {
"node": ">=v14.0.0",
"npm": ">=7.0.0"
}
},
"node_modules/discord-api-types": {
"version": "0.37.119",
"resolved": "https://registry.npmjs.org/discord-api-types/-/discord-api-types-0.37.119.tgz",
"integrity": "sha512-WasbGFXEB+VQWXlo6IpW3oUv73Yuau1Ig4AZF/m13tXcTKnMpc/mHjpztIlz4+BM9FG9BHQkEXiPto3bKduQUg=="
},
"node_modules/discord.js": {
"version": "14.17.3",
"resolved": "https://registry.npmjs.org/discord.js/-/discord.js-14.17.3.tgz",
"integrity": "sha512-8/j8udc3CU7dz3Eqch64UaSHoJtUT6IXK4da5ixjbav4NAXJicloWswD/iwn1ImZEMoAV3LscsdO0zhBh6H+0Q==",
"dependencies": {
"@discordjs/builders": "^1.10.0",
"@discordjs/collection": "1.5.3",
"@discordjs/formatters": "^0.6.0",
"@discordjs/rest": "^2.4.2",
"@discordjs/util": "^1.1.1",
"@discordjs/ws": "^1.2.0",
"@sapphire/snowflake": "3.5.3",
"discord-api-types": "^0.37.114",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"tslib": "^2.6.3",
"undici": "6.19.8"
},
"engines": {
"node": ">=18"
},
"funding": {
"url": "https://github.com/discordjs/discord.js?sponsor"
}
},
"node_modules/dotenv": {
"version": "16.4.7",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz",
"integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
"integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="
},
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"node_modules/lodash.snakecase": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/lodash.snakecase/-/lodash.snakecase-4.1.1.tgz",
"integrity": "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="
},
"node_modules/magic-bytes.js": {
"version": "1.10.0",
"resolved": "https://registry.npmjs.org/magic-bytes.js/-/magic-bytes.js-1.10.0.tgz",
"integrity": "sha512-/k20Lg2q8LE5xiaaSkMXk4sfvI+9EGEykFS4b0CHHGWqDYU0bGUFSwchNOMA56D7TCs9GwVTkqe9als1/ns8UQ=="
},
"node_modules/shrimple-locales": {
"version": "0.2.1",
"resolved": "https://registry.npmjs.org/shrimple-locales/-/shrimple-locales-0.2.1.tgz",
"integrity": "sha512-j2vNBDXJgED3XqGXCD/vqXBSqwlDXP1iGkseVos8mCtZqHp3R+0FImx8xwtjeYufJcYfhjBMkaBTWgsBi8eJZw=="
},
"node_modules/ts-mixer": {
"version": "6.0.4",
"resolved": "https://registry.npmjs.org/ts-mixer/-/ts-mixer-6.0.4.tgz",
"integrity": "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="
},
"node_modules/typescript": {
"version": "5.7.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.7.3.tgz",
"integrity": "sha512-84MVSjMEHP+FQRPy3pX9sTVV/INIex71s9TL2Gm5FG/WG1SqXeKyZ0k7/blY/4FdOzI12CBy1vGc4og/eus0fw==",
"dev": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici": {
"version": "6.19.8",
"resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz",
"integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==",
"engines": {
"node": ">=18.17"
}
},
"node_modules/ws": {
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.18.0.tgz",
"integrity": "sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}

30
bot/package.json Normal file
View File

@@ -0,0 +1,30 @@
{
"name": "plugtest",
"version": "1.0.0",
"description": "a descriptiuon",
"main": "dist/index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"build": "sern build",
"start": "node ./dist/index.js",
"run": "node ./dist/index.js"
},
"keywords": [
"typescript",
"sern",
"discord.js"
],
"license": "UNLICENSED",
"dependencies": {
"@sern/handler": "file:../",
"@sern/localizer": "^1.1.3",
"@sern/publisher": "^1.1.2",
"discord.js": "^14.15.0",
"dotenv": "^16.4.5"
},
"devDependencies": {
"@types/node": "^17.0.25",
"typescript": "latest"
},
"type": "module"
}

11
bot/rm.py Normal file
View File

@@ -0,0 +1,11 @@
import os
for root, dirs, files in os.walk('.'):
for filename in files:
if filename.endswith('.js'):
file_path = os.path.join(root, filename)
try:
os.remove(file_path)
print(f'Successfully deleted: {file_path}')
except OSError as e:
print(f'Error deleting {file_path}: {e.strerror}')

13
bot/sern.config.json Normal file
View File

@@ -0,0 +1,13 @@
{
"language": "typescript",
"defaultPrefix": "!",
"paths": {
"base": "src",
"commands": "commands",
"events": "events"
},
"app": {
"tags": ["Nice ass bot"],
"description": "A bot"
}
}

View File

37
bot/src/commands/add.ts Normal file
View File

@@ -0,0 +1,37 @@
import { CommandType, commandModule } from "@sern/handler";
import { ApplicationCommandOptionType } from "discord.js";
export default commandModule({
name: 'add',
type: CommandType.Slash,
description: 'Adds numbers together',
options: [
{
type: ApplicationCommandOptionType.String,
name: 'numbers',
description: 'Numbers to add together separated by a space.',
required: true,
min_length: 3,
},
],
execute: async (ctx) => {
let numbers = ctx.options.getString('numbers')?.split(' ')!;
numbers = numbers.filter((num) => num !== '');
if (!numbers.every((num) => !isNaN(parseFloat(num)))) {
return ctx.reply({
content: 'You can only input numbers.',
ephemeral: true,
});
}
const sum = numbers.reduce((acc, num) => acc + parseFloat(num), 0);
return ctx.reply({
content: `The sum is ${sum}`,
ephemeral: true,
});
},
});

View File

@@ -0,0 +1,8 @@
import { filter, hasRole } from "../../plugins/filter.js";
import { ownerOnly } from "../../plugins/ownerOnly.js";
import { ADMIN } from '../../constants.js'
export default [
ownerOnly(),
filter({ condition: [hasRole(ADMIN)] })
]

View File

@@ -0,0 +1,11 @@
import { commandModule, CommandType } from '@sern/handler'
export default commandModule({
type: CommandType.Slash,
description: "A",
execute: (ctx, args) => {
}
})

14
bot/src/commands/btn.ts Normal file
View File

@@ -0,0 +1,14 @@
import { CommandType, commandModule } from "@sern/handler";
import { json } from "../plugins/json-params.js";
export default commandModule({
type: CommandType.Button,
plugins: [json],
execute(ctx, args) {
console.log(args.state['json/data'])
//@ts-ignore
ctx.reply(args.state['json/data'].uid)
}
})

View File

@@ -0,0 +1,9 @@
import { CommandType, commandModule } from "@sern/handler";
export default commandModule({
type: CommandType.ChannelSelect,
execute: (s) => {
s.reply('clicked channel');
}
});

View File

@@ -0,0 +1,25 @@
import { CommandType, commandModule } from "@sern/handler";
import { publishConfig } from "@sern/publisher";
import { PermissionFlagsBits } from "discord.js";
export default commandModule({
type: CommandType.Slash,
plugins: [
publishConfig({
integrationTypes: ['User'],
contexts: [0,1,2],
defaultMemberPermissions:
PermissionFlagsBits.Speak
| PermissionFlagsBits.Connect
| PermissionFlagsBits.BanMembers
})
],
description: "yo",
execute:(ctx) => {
ctx.reply("hello");
}
})

View File

@@ -0,0 +1,103 @@
import { CommandType, Context, commandModule } from "@sern/handler";
import { ActionRowBuilder, ButtonBuilder, ButtonStyle } from "discord.js";
export default commandModule ({
type: CommandType.Slash,
description: 'collectors',
execute: async (ctx) => {
//await close(ctx)
await testCollect(ctx)
}
})
const testCollect = async (ctx: Context) => {
const msgcmpt = ctx.interaction.channel?.createMessageComponentCollector()
const buttonRow = new ActionRowBuilder<ButtonBuilder>().addComponents(
new ButtonBuilder().setCustomId("closeyes").setLabel("Yes").setStyle(ButtonStyle.Success),
new ButtonBuilder().setCustomId("closeno").setLabel("No").setStyle(ButtonStyle.Danger)
);
ctx.reply({ components: [buttonRow] })
msgcmpt?.on('collect', async button => {
await button.deferUpdate();
if (button.customId === "closeyes") {
try {
await button.editReply('closing')
} catch (e) {
await button.editReply({ content: "An error has occurred and I could not close the ticket...", components: [] });
}
} else {
await button.editReply({ content: "This ticket will remain open.", components: [] });
msgcmpt.stop();
}
});
}
//export const quiz = async(client: Client, ctx: Context) => {
// try {
// const pokemon = Math.round(Math.random() * 890)
// const question = `https://cdn.dagpi.xyz/wtp/pokemon/${pokemon}q.png`;
// const answer = `https://cdn.dagpi.xyz/wtp/pokemon/${pokemon}a.png`;
//
// const correctPokemon = await (await fetch(`https://pokeapi.co/api/v2/pokemon/${pokemon}`)).json();
// const allPokemon = await (await fetch("https://pokeapi.co/api/v2/pokemon?limit=899")).json();
//
// const options: string[] = [];
// //client.utils.log("WARNING", "INFO", `Correct answer is ${correctPokemon.name}`);
//
// while (options.length < 9) {
// let option = allPokemon.results[pokemon];
// if (options.includes(option.name)) continue;
// options.push(option.name);
// }
//
// if (!options.includes(correctPokemon.name)) {
// options.splice(client.utils.randomRange(0, 10), 0, correctPokemon.name.toLowerCase());
// } else {
// while (options.length < 10) {
// let option = allPokemon.results[client.utils.randomRange(1, 890)];
// if (options.includes(option.name)) continue;
// options.push(option.name);
// }
// }
//
// const msgEmbed = (await client.utils.CustomEmbed({ userID: ctx.user.id })).setTitle("Who's that Pokémon?").setImage(question);
// const row = new ActionRowBuilder<StringSelectMenuBuilder>().addComponents(
// new StringSelectMenuBuilder().setCustomId("pokequiz").addOptions(
// options.sort().map((opt) => {
// return { label: client.utils.titleCase(opt), value: opt.toLowerCase() };
// })
// )
// );
//
// const msg = await client.utils.fetchReply(ctx.interaction, { embeds: [msgEmbed], components: [row] });
// const filter = (i: StringSelectMenuInteraction) => i.user.id === ctx.user.id && i.message.id === msg.id;
//
// const collector = msg.createMessageComponentCollector({ filter, componentType: ComponentType.StringSelect, time: 1000 * 20 });
// collector.on("collect", async (i) => {
// const guess = i.values[0].toLowerCase();
//
// msgEmbed.setImage(answer).setTitle(`It's ${client.utils.titleCase(correctPokemon.name)}!`);
//
// if (guess === correctPokemon.name.toLowerCase()) msgEmbed.setColor("Green").setFooter({ text: "You're correct!" });
// else msgEmbed.setColor("Red").setFooter({ text: `You guessed ${client.utils.titleCase(guess)}.` });
//
// await i.update({ embeds: [msgEmbed], components: [] });
// collector.stop("Guessed");
// });
//
// collector.on("end", async (i, reason) => {
// if (reason === "Guessed") return;
//
// msgEmbed
// .setImage(answer)
// .setTitle(`It's ${client.utils.titleCase(correctPokemon.name)}!`)
// .setColor("Red")
// .setFooter({ text: "You did not guess in time." });
//
// await ctx.interaction.editReply({ embeds: [msgEmbed], components: [] });
// });
// } catch (e) {
// client.utils.log("ERROR", __filename, `${e}`);
// return ctx.interaction.reply({ content: "An error has occurred. Please try again.", ephemeral: true });
// }
//};

6
bot/src/commands/dmMe.ts Normal file
View File

@@ -0,0 +1,6 @@
import { CommandType, commandModule } from "@sern/handler";
export default commandModule({
type: CommandType.Modal,
execute: (modal) => modal.reply('thanks')
});

View File

@@ -0,0 +1,24 @@
import { commandModule, CommandType } from "@sern/handler";
import { ApplicationCommandOptionType } from "discord.js";
export default commandModule({
description: "testing",
type: CommandType.Slash,
options: [
{
name: "option",
description: "option desc",
type: ApplicationCommandOptionType.String,
required: true,
autocomplete: true,
command: {
execute: (i) => {
i.respond([{ name: "rah", value: "rah" }]);
},
},
},
],
execute: (ctx) => {
return ctx.reply("rah");
},
});

View File

@@ -0,0 +1,52 @@
import { commandModule, CommandType } from "@sern/handler";
import { ActionRowBuilder, ApplicationCommandOptionType, ModalBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
export default commandModule({
type: CommandType.Slash,
description : 'a ping command',
options: [
{
name: "nest",
description: "testing nested",
type: ApplicationCommandOptionType.SubcommandGroup,
options : [
{
name: "nest",
description: "testing nested",
type: ApplicationCommandOptionType.Subcommand,
options : [
{
name: "sdfasd",
description: "testing autocomplete",
autocomplete: true,
type: ApplicationCommandOptionType.String,
command : {
onEvent : [],
async execute(autocmp, sdt) {
//console.log(autocmp)
const choices = ['butt', 'deez', 'lmao', 'lmfao', 'nuts', 'chicken'];
await autocmp.respond(choices.map((e,i) => ({ name : e, value: i.toString()})));
}
}
}
]
},
]
},
],
async execute ({ interaction }) {
const modal = new ModalBuilder()
.setCustomId('dmMe')
.setTitle('send something to my dm (nothing bad pls)');
const input = new TextInputBuilder()
.setCustomId('message')
.setLabel("Send something to me")
.setStyle(TextInputStyle.Short);
const firstActionRow = new ActionRowBuilder<TextInputBuilder>().addComponents([input]);
modal.addComponents([firstActionRow]);
await interaction.showModal(modal);
}
});

View File

@@ -0,0 +1,8 @@
import { CommandType, commandModule } from '@sern/handler'
export default commandModule({
type: CommandType.CtxMsg,
execute: (i, sdt) => {
i.reply('pong msg')
}
})

View File

@@ -0,0 +1,9 @@
import { CommandType, commandModule } from "@sern/handler";
export default commandModule({
type: CommandType.CtxUser,
execute: (i, sdt) => {
i.reply('pong')
}
})

84
bot/src/commands/ping.ts Normal file
View File

@@ -0,0 +1,84 @@
import {commandModule, CommandType, controller, CommandInitPlugin, CommandControlPlugin } from '@sern/handler';
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ChannelSelectMenuBuilder,
RoleSelectMenuBuilder,
UserSelectMenuBuilder,
} from "discord.js";
import { localize } from '@sern/localizer';
const plugin = CommandControlPlugin(() => {
return controller.next({ a: 'from plugin1' });
});
const plugin2 = CommandControlPlugin(() => {
return controller.next({ a: 'from plugin2' });
})
const updateDescription = (description: string) => {
return CommandInitPlugin(() => {
if(description.length > 100) {
console.error("Description is invalid")
return controller.stop("From updateDescription: description is invalid");
}
return controller.next({ description }); // continue to next plugin
});
};
export default commandModule({
type: CommandType.Slash,
plugins: [localize()],
description: 'A ping command I just updated',
options: [
str(name("asdfs"),
description("sdfds"))
],
execute: async (ctx, sdt) => {
ctx.interaction
const btn = new ButtonBuilder()
.setStyle(ButtonStyle.Link)
.setLabel("Click me")
.setURL('https://www.youtube.com/watch?v=dQw4w9WgXcQ&pp=ygUIcmlja3JvbGw%3D')
const editButton = new ButtonBuilder({
customId: `btn/{"uid":"1061421834341462036"}`,
label: "click me also",
emoji: "🛠",
style: ButtonStyle.Primary,
});
ctx.reply({ components: [
new ActionRowBuilder<ButtonBuilder>().addComponents(btn, editButton),
new ActionRowBuilder<UserSelectMenuBuilder>({
components: [
new UserSelectMenuBuilder({
custom_id: "userselect",
placeholder: "select channel",
minValues: 1,
}),
],
}),
new ActionRowBuilder<ChannelSelectMenuBuilder>().addComponents(
new ChannelSelectMenuBuilder({
custom_id: "channelselect",
placeholder: "select channel",
minValues: 1,
}),
),
new ActionRowBuilder<RoleSelectMenuBuilder>({
components: [
new RoleSelectMenuBuilder({
custom_id: "roleselect",
placeholder: "select role",
minValues: 1,
}),
],
})
]})
},
});

View File

@@ -0,0 +1,9 @@
import { CommandType, commandModule } from "@sern/handler"
export default commandModule( {
type: CommandType.RoleSelect,
execute: (s) => {
s.reply('selected role')
}
})

View File

@@ -0,0 +1,12 @@
import { CommandType, commandModule } from "@sern/handler";
export default commandModule({
type: CommandType.Slash,
description: 'shid',
execute({ interaction }) {
interaction.reply('hello')
}
})

View File

@@ -0,0 +1,62 @@
import { commandModule, CommandType } from '@sern/handler';
import { ApplicationCommandOptionType } from 'discord.js';
export default commandModule({
type: CommandType.Slash,
description: 'A ping command',
options: [
{
name: "art",
type: ApplicationCommandOptionType.Subcommand,
description: "Lists out information about an Animal Crossing artwork.",
options: [
{
name: "name",
description: "The name of the artwork to lookup.",
type: ApplicationCommandOptionType.String,
autocomplete: true,
required: true,
command: {
async execute(ctx) {
await ctx.respond([{ name: 'art', value: 'first' }])
},
},
},
],
},
{
name: "villager",
type: ApplicationCommandOptionType.Subcommand,
description: "Lists out information about an Animal Crossing villager.",
options: [
{
name: "name",
description: "The name of the villager to lookup.",
type: ApplicationCommandOptionType.String,
autocomplete: true,
required: true,
command: {
onEvent: [],
async execute(ctx) {
await ctx.respond([{ name: 'villager', value: 'second' } ])
},
},
},
],
},
],
execute: async (ctx) => {
const command = ctx.options.getSubcommand();
switch (command) {
case "art": {
ctx.reply('art');
break;
}
case "villager": {
ctx.reply('vil');
break;
}
}
},
});

View File

@@ -0,0 +1,40 @@
import { ApplicationCommandOptionType } from "discord.js";
import { Service, commandModule, CommandType } from "@sern/handler";
export const config = {
guildIds: ['941002690211766332']
}
export default commandModule({
type: CommandType.Both,
description: 'tests context',
options: [
{
type: ApplicationCommandOptionType.String,
name: "hello",
description: "wassup",
required: false,
}
],
async execute(ctx) {
const logger = Service('@sern/logger');
if(ctx.isMessage()) {
logger?.info({ message : ctx.message.content })
logger?.info({ message : ctx.prefix })
} else {
logger?.info({ message : ctx.interaction.toString() })
}
logger?.info({ message: ctx.id })
logger?.info({ message: ctx.channel?.toString()! })
logger?.info({ message: ctx.user.toString()! })
logger?.info({ message: ctx.createdTimestamp.toString() })
logger?.info({ message: ctx.guild?.toString() })
logger?.info({ message: ctx.member?.toString() })
logger?.info({ message: ctx.client })
logger?.info({ message: ctx.inGuild })
await ctx.reply("guayin bodishivatta")
}
})

View File

@@ -0,0 +1,33 @@
import { CommandType, commandModule } from "@sern/handler";
import { ActionRowBuilder, ModalActionRowComponentBuilder, ModalBuilder, TextInputBuilder, TextInputStyle } from "discord.js";
const informationRequestModal = new ModalBuilder()
.setCustomId("information-request")
.setTitle("More Information")
.addComponents(
new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(
new TextInputBuilder()
.setCustomId("command-name")
.setLabel("Command Name")
.setPlaceholder("The name of the command that this bug occurred on.")
.setStyle(TextInputStyle.Short)
.setMinLength(4)
.setMaxLength(20)
.setRequired(true)));
export default commandModule({
type: CommandType.Slash,
plugins: [],
description: "A random test command.",
execute: async (ctx) => {
await ctx.interaction.showModal(informationRequestModal);
await ctx.interaction
.awaitModalSubmit({ time: 300_000 })
.then(async (modal) => {
modal.reply("thanks brody")
})
.catch(() => null);
},
});

View File

@@ -0,0 +1,8 @@
import { CommandType, commandModule } from "@sern/handler";
export default commandModule( {
type: CommandType.UserSelect,
execute: (s) => {
s.reply('selected user')
}
})

1
bot/src/constants.ts Normal file
View File

@@ -0,0 +1 @@
export const ADMIN = '983754333944434712'

18
bot/src/dependencies.d.ts vendored Normal file
View File

@@ -0,0 +1,18 @@
import type {
Logging,
ErrorHandling,
CoreDependencies
} from '@sern/handler'
import type { Publisher } from '@sern/publisher';
import type { Localizer } from '@sern/localizer';
declare global {
interface Dependencies extends CoreDependencies {
localizer: Localizer;
publisher: Publisher
}
}
export {}

10
bot/src/events/error.ts Normal file
View File

@@ -0,0 +1,10 @@
import { EventType, eventModule } from "@sern/handler";
export default eventModule({
name: 'error',
type: EventType.Sern,
execute: (e) => {
console.log(e)
}
})

View File

@@ -0,0 +1,10 @@
import { discordEvent } from "@sern/handler";
const execute = (...args: any[]) => {
console.log(args[0].content)
}
export default discordEvent({
name: 'messageCreate',
once: true,
execute
})

View File

@@ -0,0 +1,10 @@
import {eventModule, EventType} from "@sern/handler";
export default eventModule({
type: EventType.Sern,
name: 'module.activate',
execute(args) {
}
})

View File

@@ -0,0 +1,9 @@
import { CommandType, EventType, Service, eventModule } from "@sern/handler";
export default eventModule({
type: EventType.Sern,
execute: async () => {
console.log('eventmodule: all loaded');
}
})

View File

@@ -0,0 +1,8 @@
import { discordEvent } from "@sern/handler";
export default discordEvent({
name: 'threadCreate',
execute(thread) {
console.log(thread)
}
})

41
bot/src/index.ts Normal file
View File

@@ -0,0 +1,41 @@
import 'dotenv/config';
import { makeDependencies, Sern, Service } from '@sern/handler'
import { Client, GatewayIntentBits, Partials } from 'discord.js';
import { Publisher } from '@sern/publisher'
import { Localization } from '@sern/localizer'
__DEV__: console.log(1);
const intents = GatewayIntentBits.Guilds |
GatewayIntentBits.GuildMembers |
GatewayIntentBits.GuildMessageReactions |
GatewayIntentBits.GuildMessages |
GatewayIntentBits.DirectMessages |
GatewayIntentBits.MessageContent;
const partials = [
Partials.Channel
];
async function init() {
await makeDependencies(({ add }) => {
add('@sern/client', new Client({ intents, partials }));
add('localizer', Localization());
add('publisher', deps => {
return new Publisher(deps['@sern/modules'],
deps['@sern/emitter'],
deps['@sern/logger']!)
})
})
Sern.init({
commands : "./dist/commands",
events: "./dist/events",
tasks: "./dist/tasks",
defaultPrefix: "!"
})
}
init().then(() => {
Service('@sern/client').login()
})
//View docs for all options

156
bot/src/plugins/args.ts Normal file
View File

@@ -0,0 +1,156 @@
/**
* @author HighArcs
* @version 1.0.0
* @description converts array of argument strings to an object (and maps them)
* @license null
* @example
* ```ts
* import { parsedCommandModule, args } from "../plugins/args";
* import { CommandType } from "@sern/handler";
*
* interface Arg {
* value: number;
* }
*
* export default parsedCommandModule({
* type : CommandType.Text
* plugins: [args({ value: Number })],
* execute: (ctx, args) => {
* console.log(ctx.args.value);
* }
* })
*/
import {
commandModule,
CommandType,
Context, ControlPlugin,
Plugin, CommandControlPlugin, controller
} from "@sern/handler";
import type { Awaitable } from "discord.js";
type Converter<T> = (value?: string) => Awaitable<T>;
type Struct = Record<string, any>;
type ConverterList<T extends Struct> = {
[K in keyof T]: Converter<T[K]>;
};
type Ctx<T> = Context & { _args: T };
interface Err {
key: string;
error: string;
given: string;
index: number;
}
type OnError<T> = (context: Ctx<T>, error: Err) => any;
type SpecialEvt<T> = {
readonly "@@plugin": symbol
} & ControlPlugin<[Ctx<T>, ]>
async function convert<T extends Struct>(
args: Array<string>,
struct: ConverterList<T>
) {
const entries = Object.entries(struct);
const result = {} as T;
for (let i = 0; i < entries.length; i++) {
const value = args[i];
const [key, converter] = entries[i]!;
try {
result[key as keyof T] = await converter(value);
} catch (error) {
throw { key, error: String(error), given: value, index: i };
}
}
return result;
}
interface ParsedInputCommandModule<T extends Struct> {
name?: string;
description: string;
type: CommandType.Both | CommandType.Text | CommandType.Slash;
execute: (context: Ctx<T>, args: Array<string>) => any;
plugins: () =>
| [SpecialEvt<T>, ...Array<Plugin>]
| []
| undefined;
}
export const Structs = {
string: (value: string) => String(value),
number: (value: string) => Number(value),
boolean: (value: string) => value === "true" || value === "1",
date: (value: string) => new Date(value),
integer: (value: string) => Number.parseInt(value),
};
export function parsedCommandModule<T extends Struct>(
a: ParsedInputCommandModule<T>
) {
const plugins = (a.plugins() ?? []);
return commandModule({ ...a, plugins } as never);
}
export namespace Checks {
export function choices<K extends string>(
choices: K[],
value?: string
): asserts value is K {
if (!choices.includes(value as unknown as K)) {
throw "value is not in choices";
}
}
export function required(value?: string): asserts value is string {
if (value === undefined) {
throw "value is required";
}
}
export function limit(min: number, max: number, value?: string) {
required(value);
const val = Structs.number(value);
if (val < min) {
throw `value must be higher than ${min}`;
}
if (val > max) {
throw `value must be lower than ${max}`;
}
return val;
}
}
export function args<T extends Struct>(
struct: ConverterList<T>,
onError?: OnError<T>
): SpecialEvt<T> {
const plugin = CommandControlPlugin<CommandType.Both>(async (ctx, args) => {
switch(args.type) {
case "slash": {
let result: T;
} break;
case "text" : {
let result: T;
try {
result = await convert(args, struct)
} catch (e) {
if (onError) {
onError(ctx as Ctx<T>, e as Err);
}
return controller.stop();
}
//@warn - mutable assignment!
(ctx as Ctx<T>)._args = result;
return controller.next();
}
}
return controller.next()
})
Object.defineProperty(plugin, "@@plugin", { value: Symbol("args") })
return plugin as SpecialEvt<T>;
}

View File

@@ -0,0 +1,57 @@
/**
* This plugin checks the fields of a ModalSubmitInteraction
* with regex or a custom callback
*
* @author @jacoobes [<@182326315813306368>]
* @version 1.0.0
* @example
* ```ts
* export default commandModule({
* type: CommandType.Modal,
* plugins: [
* assertFields({
* fields: {
* // check the modal field "mcUsernameInput" with the regex /a+b+c/
* mcUsernameInput: /a+b+c+/
* },
* failure: (errors, interaction) => {
* interaction.reply(errors.join("\n"))
* }
* }),
* ],
* execute: ctx => {
* ctx.reply("nice!")
* }
* })
* ```
*/
import { CommandControlPlugin, CommandType, controller } from "@sern/handler";
import type { ModalSubmitInteraction } from "discord.js";
type Assertion =
| RegExp
| ((value : string) => boolean);
export function assertFields(config: {
fields: Record<string, Assertion>,
failure: (errors: string[], interaction: ModalSubmitInteraction) => any
}) {
return CommandControlPlugin<CommandType.Modal>(modal => {
const pairs = Object.entries(config.fields);
const errors = [];
for(const [ field, assertion ] of pairs) {
// Keep in mind this doesn't check for typos!
// feel free to add more checks.
const input = modal.fields.getTextInputValue(field)
const resolvedAssertion = assertion instanceof RegExp ? (value: string) => assertion.test(value) : assertion;
if(!resolvedAssertion(input)) {
errors.push(input + " failed to pass assertion " + resolvedAssertion.toString() )
}
}
if(errors.length > 0) {
config.failure(errors, modal);
return controller.stop();
}
return controller.next();
})
}

View File

@@ -0,0 +1,39 @@
/**
* This plugin checks if a channel is the specified type
*
* @author @Benzo-Fury [<@762918086349029386>]
* @version 1.0.0
* @example
* ```ts
* import { channelType } from "../plugins/channelType";
* import { ChannelType } from "discord.js"
* import { commandModule } from "@sern/handler";
* export default commandModule({
* plugins: [ channelType([ChannelType.GuildText], 'This cannot be used here') ],
* execute: (ctx) => {
* //your code here
* }
* })
* ```
*/
import { ChannelType } from "discord.js";
import {CommandControlPlugin, CommandType, controller } from "@sern/handler";
export function channelType(
channelType: ChannelType[],
onFail?: string
){
return CommandControlPlugin<CommandType.Both>(async (ctx) => {
let channel = ctx.channel?.type;
//for some reason the dm channel type was returning undefined at some points
if (channel === undefined) {
channel = ChannelType.DM;
}
if (channelType.includes(channel)) {
return controller.next();
}
if (onFail) {
await ctx.reply(onFail);
}
return controller.stop();
})
}

View File

@@ -0,0 +1,106 @@
/**
* This is buttonConfirmation plugin, it runs confirmation prompt in the form of buttons.
* Note that you need to use edit/editReply in the command itself because we are already replying in the plugin!
* Credits to original plugin of confirmation using reactions and its author!
*
* @author @EvolutionX-10 [<@697795666373640213>]
* @version 1.0.0
* @example
* ```ts
* import { buttonConfirmation } from "../plugins/buttonConfirmation";
* import { commandModule } from "@sern/handler";
* export default commandModule({
* plugins: [ buttonConfirmation() ],
* execute: (ctx) => {
* //your code here
* }
* })
* ```
*/
import {CommandControlPlugin, CommandType, controller} from "@sern/handler";
import {
ActionRowBuilder,
ButtonBuilder,
ButtonStyle,
ComponentType,
} from "discord.js";
export function confirmation(
options?: Partial<ConfirmationOptions>
) {
return CommandControlPlugin<CommandType.Both>(async (ctx, args) => {
options = {
content: "Do you want to proceed?",
denialMessage: "Cancelled",
labels: ["No", "Yes"],
time: 60_000,
wrongUserResponse: "Not for you!",
...options,
};
const buttons = options.labels!.map((l, i) => {
return new ButtonBuilder()
.setCustomId(l)
.setLabel(l)
.setStyle( i === 0 ? ButtonStyle.Danger : ButtonStyle.Success
);
});
const sent = await ctx.reply({
content: options.content,
components: [
new ActionRowBuilder<ButtonBuilder>().setComponents(
buttons
),
],
});
const collector = sent.createMessageComponentCollector({
componentType: ComponentType.Button,
filter: (i) => i.user.id === ctx.user.id,
time: options.time,
});
return new Promise((resolve) => {
collector.on("collect", async (i) => {
await i.update({ components: [] });
collector.stop();
if (i.customId === options!.labels![1]) {
resolve(controller.next());
return;
}
await i.editReply({
content: options?.denialMessage,
});
resolve(controller.stop());
});
collector.on("end", async (c) => {
if (c.size) return;
buttons.forEach((b) => b.setDisabled());
await sent.edit({
components: [
new ActionRowBuilder<ButtonBuilder>().setComponents(
buttons
),
],
});
});
collector.on("ignore", async (i) => {
await i.reply({
content: options?.wrongUserResponse,
ephemeral: true,
});
});
});
});
}
interface ConfirmationOptions {
content: string;
denialMessage: string;
time: number;
labels: [string, string];
wrongUserResponse: string;
}

View File

@@ -0,0 +1,6 @@
import {CommandInitPlugin, controller} from "@sern/handler";
export const correctFile = CommandInitPlugin(() => {
return controller.stop()
})

View File

@@ -0,0 +1,38 @@
// @ts-nocheck
/**
* Disables a command entirely, for whatever reasons you may need.
*
* @author @jacoobes [<@182326315813306368>]
* @version 1.0.0
* @example
* ```ts
* import { disable } from "../plugins/disable";
* import { commandModule } from "@sern/handler";
* export default commandModule({
* plugins: [ disable() ],
* execute: (ctx) => {
* //your code here
* }
* })
* ```
*/
import { CommandType, EventPlugin, PluginType } from "@sern/handler";
import { InteractionReplyOptions, ReplyMessageOptions } from "discord.js";
export function disable(
onFail?:
| string
| Omit<InteractionReplyOptions, "fetchReply">
| ReplyMessageOptions
): EventPlugin<CommandType.Both> {
return {
type: PluginType.Event,
description: "Disables command from responding",
async execute([ctx], controller) {
if (onFail !== undefined) {
await ctx.reply(onFail);
}
return controller.stop();
},
};
}

29
bot/src/plugins/dmOnly.ts Normal file
View File

@@ -0,0 +1,29 @@
/**
* This is dmOnly plugin, it allows commands to be run only in DMs.
* For discord.js you should have the Partials.Channel and DirectMessages intent enabled.
* @author @EvolutionX-10 [<@697795666373640213>]
* @version 1.0.0
* @example
* ```ts
* import { dmOnly } from "../plugins/dmOnly";
* import { commandModule } from "@sern/handler";
* export default commandModule({
* plugins: [dmOnly()],
* execute: (ctx) => {
* //your code here
* }
* })
* ```
*/
import {CommandControlPlugin, CommandType, controller } from "@sern/handler";
export function dmOnly(
content?: string,
ephemeral?: boolean
) {
return CommandControlPlugin<CommandType.Both>(async (ctx, _) => {
if (ctx.channel?.isDMBased()) return controller.next();
if (content) await ctx.reply({ content, ephemeral }); // Change this if you want or remove it for silent deny
return controller.stop();
})
}

637
bot/src/plugins/filter.ts Normal file
View File

@@ -0,0 +1,637 @@
import {
CommandControlPlugin,
type CommandType,
type Context,
controller,
} from "@sern/handler";
import {
GuildMember,
GuildMemberRoleManager,
PermissionResolvable,
PermissionsBitField,
User,
} from "discord.js";
export type Test = (context: Context) => boolean;
export class Criteria {
public constructor(
public readonly name: string,
public readonly execute: Test,
public readonly children: Array<Criteria>
) {}
toString() {
return this.name + ' ' + this.children.map(c => c.name).join(', ')
}
}
export const or = (...filters: Array<FilterImpl>): FilterImpl => {
function execute(context: Context): boolean {
let pass = false;
tests: for (const filter of filters) {
if (filter.test(context)) {
pass = true;
break tests;
}
}
return pass;
}
const children: Array<Criteria> = filters.map((x) => x.criteria);
return new FilterImpl(
new Criteria("or", execute, children),
`or(${filters.map((x) => x.message).join(", ")})`
);
}
export const and = (...filters: Array<FilterImpl>): FilterImpl => {
function execute(context: Context): boolean {
for (const filter of filters) {
if (!filter.test(context)) {
return false;
}
}
return true;
}
const children: Array<Criteria> = filters.map((x) => x.criteria);
return new FilterImpl(
new Criteria("and", execute, children),
`and(${filters.map((x) => x.message).join(", ")})`
);
}
export const not = (filter: FilterImpl): FilterImpl => {
function execute(context: Context): boolean {
return !filter.test(context);
}
return new FilterImpl(
new Criteria("not", execute, [filter.criteria]),
`not(${filter.criteria})`
);
}
export const custom =(execute: Test, message?: string): FilterImpl => {
return new FilterImpl(new Criteria("custom", execute, []), message);
}
export const withCustomMessage = (
filter: FilterImpl,
message?: string
): FilterImpl => {
return new FilterImpl(filter.criteria, message);
}
export const hasGuildPermission = (
permission: PermissionResolvable
): FilterImpl => {
const b = PermissionsBitField.resolve(permission);
const field = Object.entries(PermissionsBitField.Flags).find(
([, v]) => v === b
);
if (field === undefined) {
throw new Error(
`unknown permission \`${permission}\` in filter \`hasGuildPermission\``
);
}
const [name] = field;
function execute(context: Context): boolean {
if (context.member !== null) {
if (typeof context.member.permissions === "string") {
return new PermissionsBitField(BigInt(context.member.permissions)).has(b);
}
return context.member.permissions.has(b);
}
return true;
}
return new FilterImpl(
new Criteria("hasGuildPermission", execute, []),
`has guild permission: ${name}`
);
}
export const hasChannelPermission = (
permission: PermissionResolvable,
channelId?: string
): FilterImpl => {
const b = PermissionsBitField.resolve(permission);
const field = Object.entries(PermissionsBitField.Flags).find(
([, v]) => v === b
);
if (field === undefined) {
throw new Error(
`unknown permission \`${permission}\` in filter \`hasChannelPermission\``
);
}
const [name] = field;
function execute(context: Context): boolean {
if (context.member !== null) {
const channel =
channelId !== undefined
? context.guild?.channels.cache.get(channelId)
: context.channel;
// ?
if (channel == undefined || channel === null) {
return false;
}
if (channel.isDMBased()) {
return true;
}
const field2 = channel.permissionsFor(context.user);
// assume we have no permission overrides
if (field2 === null) {
if (context.member !== null) {
if (typeof context.member.permissions === "string") {
return new PermissionsBitField(
BigInt(context.member.permissions)
).has(b);
}
return context.member.permissions.has(b);
}
return false;
}
return field2.has(b);
}
return true;
}
return new FilterImpl(
new Criteria("hasChannelPermission", execute, []),
channelId !== undefined
? `has channel permission ${name} in <#${channelId}>`
: `has channel permission ${name}`
);
}
export const canAddReactions =(channelId?: string): FilterImpl => {
return hasChannelPermission("AddReactions", channelId);
}
export const canAttachFiles =(channelId?: string): FilterImpl => {
return hasChannelPermission("AttachFiles", channelId);
}
export const canBanMembers = (): FilterImpl => {
return hasGuildPermission("BanMembers");
}
export const canChangeNickname = (): FilterImpl => {
return hasGuildPermission("ChangeNickname");
}
export const canConnect = (channelId?: string): FilterImpl => {
return hasChannelPermission("Connect", channelId);
}
export const canCreateInstantInvite =(channelId?: string): FilterImpl => {
return hasChannelPermission("CreateInstantInvite", channelId);
}
export const canDeafenMembers =(channelId?: string): FilterImpl => {
return hasChannelPermission("DeafenMembers", channelId);
}
export const canEmbedLinks =(channelId?: string): FilterImpl => {
return hasChannelPermission("EmbedLinks", channelId);
}
export const canKickMembers =(): FilterImpl => {
return hasGuildPermission("KickMembers");
}
export const canManageChannelWebhooks =(channelId?: string): FilterImpl => {
return hasChannelPermission("ManageWebhooks", channelId);
}
export const canManageChannels =(channelId?: string): FilterImpl => {
return hasChannelPermission("ManageChannels", channelId);
}
export const canManageEmojisAndStickers =(): FilterImpl => {
return hasGuildPermission("ManageEmojisAndStickers");
}
export const canManageGuild =(): FilterImpl => {
return hasGuildPermission("ManageGuild");
}
export const canManageGuildWebhooks =(): FilterImpl => {
return hasGuildPermission("ManageWebhooks");
}
export const canManageMessages =(channelId?: string): FilterImpl => {
return hasChannelPermission("ManageMessages", channelId);
}
export const canManageNicknames = (): FilterImpl => {
return hasGuildPermission("ManageNicknames");
}
export const canManageRoles = (): FilterImpl => {
return hasGuildPermission("ManageRoles");
}
export const canMentionEveryone = (channelId?: string): FilterImpl => {
return hasChannelPermission("MentionEveryone", channelId);
}
export const canMoveMembers = (channelId?: string): FilterImpl => {
return hasChannelPermission("MoveMembers", channelId);
}
export const canMuteMembers = (channelId?: string): FilterImpl => {
return hasChannelPermission("MuteMembers", channelId);
}
export const canPrioritySpeaker = (channelId?: string): FilterImpl => {
return hasChannelPermission("PrioritySpeaker", channelId);
}
export const canReadMessageHistory = (channelId?: string): FilterImpl => {
return hasChannelPermission("ReadMessageHistory", channelId);
}
export const canViewChannel = (channelId: string): FilterImpl => {
return hasChannelPermission("ViewChannel", channelId);
}
export const canSendMessages = (channelId: string): FilterImpl => {
return hasChannelPermission("SendMessages", channelId);
}
export const canSendTtsMessages = (channelId?: string): FilterImpl => {
return hasChannelPermission("SendTTSMessages", channelId);
}
export const canSpeak = (channelId?: string): FilterImpl => {
return hasChannelPermission("Speak", channelId);
}
export const canStream = (channelId?: string): FilterImpl => {
return hasChannelPermission("Stream", channelId);
}
export const canUseExternalEmojis = (channelId?: string): FilterImpl => {
return hasChannelPermission("UseExternalEmojis", channelId);
}
export const canUseVoiceActivity = (channelId?: string): FilterImpl => {
return hasChannelPermission("UseVAD", channelId);
}
export const canViewAuditLog = (): FilterImpl => {
return hasGuildPermission("ViewAuditLog");
}
export const canViewGuildInsights = (): FilterImpl => {
return hasGuildPermission("ViewGuildInsights");
}
export const channelIdIn = (channelIds: Array<string>): FilterImpl => {
function execute(context: Context): boolean {
return channelIds.includes(
context.isMessage()
? context.message.channelId
: context.interaction.channelId
);
}
return new FilterImpl(
new Criteria("channelIdIn", execute, []),
`channel is one of: ${channelIds.map((v) => `<#${v}>`).join(", ")}`
);
}
export const hasEveryRole = (roles: Array<string>): FilterImpl => {
return withCustomMessage(
and(...roles.map((v) => hasRole(v))),
`has all of: ${roles.map((v) => `<@&${v}>`).join(", ")}`
);
}
export const hasMentionableRole = (): FilterImpl => {
function execute(context: Context): boolean {
if (context.member !== null) {
if (context.member.roles instanceof GuildMemberRoleManager) {
return (
context.member.roles.cache.filter((x) => x.mentionable === true)
.size > 0
);
}
if (context.guild === null) {
return false;
}
return context.member.roles
.map((roleId) => context.guild!.roles.cache.get(roleId))
.filter((x) => x !== undefined)
.some((x) => x!.mentionable);
}
return false;
}
return new FilterImpl(
new Criteria("hasMentionableRole", execute, []),
"has a mentionable role"
);
}
export const hasNickname = (nickname?: string): FilterImpl => {
function execute(context: Context): boolean {
if (context.member !== null) {
if (context.member instanceof GuildMember) {
if (nickname !== null) {
return context.member.nickname === nickname;
}
return context.member.nickname !== null;
}
if (nickname !== null) {
return context.member.nick === nickname;
}
return (
context.member.nick !== null && context.member.nick !== undefined
);
}
// dm members can technically have nicknames but they're per-user, so this should never be true.
return false;
}
return new FilterImpl(new Criteria("hasNickname", execute, []), "has a nickname");
}
export const hasParentId = (parentId: string): FilterImpl => {
function execute(context: Context): boolean {
if (context.channel !== null) {
if (context.channel.isDMBased()) {
return false;
}
return context.channel.parentId === parentId;
}
return false;
}
return new FilterImpl(
new Criteria("hasParentId", execute, []),
`has channel parent <#${parentId}>`
);
}
export const hasRole = (roleId: string): FilterImpl => {
function execute(context: Context): boolean {
if (context.member !== null) {
if (context.member.roles instanceof GuildMemberRoleManager) {
return context.member.roles.cache.has(roleId);
}
if (context.guild === null) {
return false;
}
return context.member.roles.includes(roleId);
}
// assume dm members have every role ever.
return true;
}
return new FilterImpl(
new Criteria("hasRole", execute, []),
`has role <@&${roleId}>`
);
}
export const hasSomeRole = (roles: Array<string>): FilterImpl => {
return withCustomMessage(
or(...roles.map((role) => hasRole(role))),
`has any of: ${roles.map((v) => `<@&${v}>`).join(", ")}`
);
}
export const isAdministator = (): FilterImpl => {
return hasGuildPermission("Administrator");
}
export const isChannelId = (channelId: string): FilterImpl => {
function execute(context: Context): boolean {
if (context.isMessage()) {
return context.message.channelId === channelId;
}
return context.interaction.channelId === channelId;
}
return new FilterImpl(
new Criteria("isChannelId", execute, []),
`is channel <#${channelId}>`
);
}
export const isChannelNsfw = (): FilterImpl => {
function execute(context: Context): boolean {
if (context.channel !== null) {
if (context.channel.isDMBased() || context.channel.isThread()) {
return false;
}
return context.channel.nsfw;
}
return false;
}
return new FilterImpl(
new Criteria("isChannelNsfw", execute, []),
"channel marked as nsfw"
);
}
export const isGuildOwner = (): FilterImpl => {
function execute(context: Context): boolean {
if (context.guild !== null) {
return context.guild.ownerId === context.user.id;
}
return true;
}
return new FilterImpl(
new Criteria("isGuildOwner", execute, []),
"is guild owner"
);
}
export const isBotOwner = (): FilterImpl => {
function execute(context: Context): boolean {
if (context.client.application !== null) {
if (context.client.application.owner !== null) {
if (context.client.application.owner instanceof User) {
return context.user.id === context.client.application.owner.id;
}
return context.client.application.owner.members.has(context.user.id);
}
}
// nope
return false;
}
return new FilterImpl(new Criteria("isBotOwner", execute, []), "is bot owner");
}
export const isUserId = (userId: string): FilterImpl => {
function execute(context: Context): boolean {
return context.user.id === userId;
}
return new FilterImpl(
new Criteria("isUserId", execute, []),
`is user: <@${userId}>`
);
}
export const parentIdIn = (parentIds: Array<string>): FilterImpl => {
return withCustomMessage(
or(...parentIds.map((v) => hasParentId(v))),
`channel parent is one of: ${parentIds.map((v) => `<#${v}>`).join(", ")}`
);
}
export const userIdIn = (userIds: Array<string>): FilterImpl => {
return withCustomMessage(
or(...userIds.map((v) => isUserId(v))),
`user is one of: ${userIds.map((v) => `<@${v}>`).join(", ")}`
);
}
export const isInGuild = (): FilterImpl => {
function execute(context: Context): boolean {
return context.guildId !== null;
}
return new FilterImpl(new Criteria("isInGuild", execute, []), "is in guild");
}
export const isInDm = (): FilterImpl => {
const notInGuild = compose(not, isInGuild);
return withCustomMessage(notInGuild(), "is in dm");
}
export const never = (): FilterImpl => {
function execute(context: Context): boolean {
void context;
return false;
}
return new FilterImpl(new Criteria("never", execute, []), "never");
}
export const always = (): FilterImpl => {
function execute(context: Context): boolean {
void context;
return true;
}
return new FilterImpl(new Criteria("always", execute, []), "always");
}
type CtxMap<T> = (arg: T) => FilterImpl;
/**
* Call FilterImpls in right to left order.
* @example
* import { compose, isUserId, not } from '../plugins/filter'
* const isNotUserId = compose(not, isUserId)
*
*/
export const compose = <T = void>(...funcs: CtxMap<any>[]): CtxMap<T> => {
return (arg: T): FilterImpl =>
//@ts-ignore
funcs.reduceRight((result, func) => func(result), arg);
}
export class FilterImpl {
public readonly test: Test;
public constructor(
public readonly criteria: Criteria,
public message?: string
) {
this.test = this.criteria.execute;
}
}
export type FilterOptions = {
condition: Array<FilterImpl> | FilterImpl,
onFailed?: (context: Context, filters: Array<FilterImpl>) => unknown
};
/**
* Generalized `filter` plugin. revised by jacoobes, all credit to original author.
* Perform declarative conditionals as plugins.
* @author @trueharuu [<@504698587221852172>]
* @version 2.0.0
* @example
* import { filter, not, isGuildOwner, canMentionEveryone } from '../plugins/filter';
* import { commandModule } from '@sern/handler';
*
* export default commandModule({
* plugins: filter({ condition: [not(isGuildOwner()), canMentionEveryone()] }),
* async execute(context) {
* // your code here
* }
* });
*/
export const filter =
(options: FilterOptions) => {
return CommandControlPlugin<CommandType.Both>(async (context) => {
const arrayifiedCondition = Array.isArray(options.condition) ? options.condition : [options.condition]
const value = and(...arrayifiedCondition).test(context);
if (value) {
return controller.next();
}
if (options.onFailed !== undefined) {
await options.onFailed(context, arrayifiedCondition);
} else {
await context.reply({
ephemeral: true,
content: `you do not match the criteria for this command:\n${arrayifiedCondition
.map((x) => x.message)
.filter((x) => x !== undefined)
.join("\n")}`,
allowedMentions: {
repliedUser: false,
parse: [],
},
});
}
return controller.stop();
});
};

View File

@@ -0,0 +1,40 @@
import { PluginType, makePlugin, controller, ControlPlugin } from "@sern/handler";
import type { AutocompleteInteraction } from 'discord.js'
/**
* @plugin
* filters autocomplete interaction that pass the criteria
* @author @jacoobes [<@182326315813306368>]
* @version 1.0.0
* @example
* ```ts
* import { CommandType, commandModule } from "@sern/handler";
* import { filterA } from '../plugins/filterA.js'
* export default commandModule({
* type : CommandType.Slash,
* options: [
* {
* autocomplete: true,
* command : {
* //only accept autocomplete interactions that include 'poo' in the text
* onEvent: [filterA(s => s.includes('poo'))],
* execute: (autocomplete) => {
* let data = [{ name: 'pooba', value: 'first' }, { name: 'pooga', value: 'second' }]
* autocomplete.respond(data)
* }
* }
* }
* ],
* execute: (ctx, args) => {}
* })
* @end
*/
export const filterA = (pred: (value: string) => boolean) => {
return makePlugin(PluginType.Control, (a: AutocompleteInteraction) => {
if(pred(a.options.getFocused())) {
return controller.next();
}
return controller.stop();
}) as ControlPlugin;
}

View File

@@ -0,0 +1,36 @@
//@ts-nocheck
/**
* @plugin
* fromCallback turns a callback into a plugin result.
* if the callback returns truthy value, plugin continues.
* This control plugin works for every command type. The arguments of the callback
* mirror the execute method on the current module.
* @author @jacoobes [<@182326315813306368>]
* @version 1.0.0
* @example
* ```ts
* const myServer = "941002690211766332";
* export default commandModule({
* type: CommandType.Both,
* plugins: [
* fromCallback((ctx, args) => ctx.guildId == myServer)
* ],
* execute: ctx => {
* ctx.reply("I only respond in myServer!");
* }
* })
* ```
* @end
*/
import { PluginType, makePlugin, controller } from "@sern/handler";
export const fromCallback = (cb: (...args: any[]) => boolean) =>
makePlugin(PluginType.Control, (...args) => {
console.log(args)
if(cb.apply(null, args)) {
return controller.next();
}
return controller.stop();
});

View File

@@ -0,0 +1,5 @@
import { CommandControlPlugin, CommandType, controller } from "@sern/handler";
export const json = CommandControlPlugin<CommandType.Button>((ctx, args) => {
return controller.next({ 'json/data': JSON.parse(args.params!) });
})

View File

@@ -0,0 +1,48 @@
/**
* This plugin checks if the channel is nsfw and responds to user with a specified response if not nsfw
*
* @author @Benzo-Fury [<@762918086349029386>]
* @version 1.0.0
* @example
* ```ts
* import { nsfwOnly } from "../plugins/nsfwOnly";
* import { commandModule } from "@sern/handler";
* export default commandModule({
* plugins: [ nsfwOnly('response', true) ],
* execute: (ctx) => {
* //your code here
* }
* })
* ```
*/
import {
ChannelType,
GuildTextBasedChannel,
TextBasedChannel,
TextChannel,
} from "discord.js";
import {CommandControlPlugin, CommandType, controller } from "@sern/handler";
function isGuildText(channel: TextBasedChannel|null): channel is GuildTextBasedChannel {
return (channel?.type == ChannelType.GuildPublicThread ||
channel?.type == ChannelType.GuildPrivateThread);
}
export function nsfwOnly(onFail: string, ephemeral: boolean) {
return CommandControlPlugin<CommandType.Both>(async (ctx, _) => {
if (ctx.guild === null) {
await ctx.reply({ content: onFail, ephemeral });
return controller.stop();
}
//channel is thread (not supported by nsfw)
if (isGuildText(ctx.channel) == true) {
await ctx.reply({ content: onFail, ephemeral });
return controller.stop();
}
if (!(ctx.channel! as TextChannel).nsfw) {
//channel is not nsfw
await ctx.reply({ content: onFail, ephemeral });
return controller.stop();
}
//continues to command if nsfw
return controller.next();
});
}

View File

@@ -0,0 +1,30 @@
// @ts-nocheck
/**
* This is OwnerOnly plugin, it allows only bot owners to run the command, like eval.
*
* @author @EvolutionX-10 [<@697795666373640213>]
* @version 1.2.0
* @example
* ```ts
* import { ownerOnly } from "../plugins/ownerOnly";
* import { commandModule } from "@sern/handler";
* export default commandModule({
* plugins: [ ownerOnly() ], // can also pass array of IDs to override default owner IDs
* execute: (ctx) => {
* //your code here
* }
* })
* ```
*/
import { CommandType, CommandControlPlugin, controller } from "@sern/handler";
const ownerIDs = ["182326315813306368"]; //! Fill your ID
export function ownerOnly(override?: string[]) {
return CommandControlPlugin<CommandType.Both>((ctx) => {
if ((override ?? ownerIDs).includes(ctx.user.id))
return controller.next();
//* If you want to reply when the command fails due to user not being owner, you can use following
// await ctx.reply("Only owner can run it!!!");
return controller.stop(); //! Important: It stops the execution of command!
});
}

View File

@@ -0,0 +1,39 @@
// @ts-nocheck
/**
* @plugin
* This is perm check, it allows users to parse the permission you want and let the plugin do the rest. (check user for that perm).
*
* @author @Benzo-Fury [<@762918086349029386>]
* @version 1.0.1
* @example
* ```ts
* import { permCheck } from "../plugins/permCheck";
* import { commandModule } from "@sern/handler";
* export default commandModule({
* plugins: [ permCheck('permission', 'No permission response') ],
* execute: (ctx) => {
* //your code here
* }
* })
* ```
* @end
*/
import type { GuildMember, PermissionResolvable } from "discord.js";
import { CommandControlPlugin, CommandType, controller } from "@sern/handler";
export function permCheck(perm: PermissionResolvable, response: string) {
return CommandControlPlugin<CommandType.Both>(async (ctx, args) => {
if (ctx.guild === null) {
await ctx.reply("This command cannot be used here");
console.warn(
"PermCheck > A command stopped because we couldn't check a users permissions (was used in dms)",
); //delete this line if you dont want to be notified when a command is used outside of a guild/server
return controller.stop();
}
if (!(ctx.member! as GuildMember).permissions.has(perm)) {
await ctx.reply(response);
return controller.stop();
}
return controller.next();
});
}

215
bot/src/plugins/publish.ts Normal file
View File

@@ -0,0 +1,215 @@
// @ts-nocheck
/**
* @plugin
* [DEPRECATED] It allows you to publish your application commands using the discord.js library with ease.
*
* @author @EvolutionX-10 [<@697795666373640213>]
* @version 2.0.0
* @example
* ```ts
* import { publish } from "../plugins/publish";
* import { commandModule } from "@sern/handler";
* export default commandModule({
* plugins: [ publish() ], // put an object containing permissions, ids for guild commands, boolean for dmPermission
* // plugins: [ publish({ guildIds: ['guildId'], defaultMemberPermissions: 'Administrator'})]
* execute: (ctx) => {
* //your code here
* }
* })
* ```
* @end
*/
import {
CommandInitPlugin,
CommandType,
controller,
SernOptionsData,
SlashCommand,
Service,
} from "@sern/handler";
import {
ApplicationCommandData,
ApplicationCommandType,
ApplicationCommandOptionType,
PermissionResolvable,
} from "discord.js";
export const CommandTypeRaw = {
[CommandType.Both]: ApplicationCommandType.ChatInput,
[CommandType.CtxUser]: ApplicationCommandType.User,
[CommandType.CtxMsg]: ApplicationCommandType.Message,
[CommandType.Slash]: ApplicationCommandType.ChatInput,
} as const;
export function publish<
T extends
| CommandType.Both
| CommandType.Slash
| CommandType.CtxMsg
| CommandType.CtxUser,
>(options?: PublishOptions) {
return CommandInitPlugin<T>(async ({ module }) => {
// Users need to provide their own useContainer function.
let client;
try {
client = (await import("@sern/handler")).Service("@sern/client");
} catch {
const { useContainer } = await import("../index.js");
client = useContainer("@sern/client")[0];
}
const defaultOptions = {
guildIds: [],
dmPermission: undefined,
defaultMemberPermissions: null,
};
options = { ...defaultOptions, ...options } as PublishOptions &
ValidPublishOptions;
let { defaultMemberPermissions, dmPermission, guildIds } =
options as unknown as ValidPublishOptions;
function c(e: unknown) {
console.error("publish command didnt work for", module.name);
console.error(e);
}
const log =
(...message: any[]) =>
() =>
console.log(...message);
const logged = (...message: any[]) => log(message);
/**
* a local function that returns either one value or the other,
* depending on {t}'s CommandType. If the commandtype of
* this module is CommandType.Both or CommandType.Text or CommandType.Slash,
* return 'is', else return 'els'
* @param t
* @returns S | T
*/
const appCmd = <V extends CommandType, S, T>(t: V) => {
return (is: S, els: T) => ((t & CommandType.Both) !== 0 ? is : els);
};
const curAppType = CommandTypeRaw[module.type];
const createCommandData = () => {
const cmd = appCmd(module.type);
return {
name: module.name,
type: curAppType,
description: cmd(module.description, ""),
options: cmd(
optionsTransformer((module as SlashCommand).options ?? []),
[],
),
defaultMemberPermissions,
dmPermission,
} as ApplicationCommandData;
};
try {
const commandData = createCommandData();
if (!guildIds.length) {
const cmd = (await client.application!.commands.fetch()).find(
(c) => c.name === module.name && c.type === curAppType,
);
if (cmd) {
if (!cmd.equals(commandData, true)) {
logged(
`Found differences in global command ${module.name}`,
);
cmd.edit(commandData).then(
log(
`${module.name} updated with new data successfully!`,
),
);
}
return controller.next();
}
client
.application!.commands.create(commandData)
.then(log("Command created", module.name))
.catch(c);
return controller.next();
}
for (const id of guildIds) {
const guild = await client.guilds.fetch(id).catch(c);
if (!guild) continue;
const guildCmd = (await guild.commands.fetch()).find(
(c) => c.name === module.name && c.type === curAppType,
);
if (guildCmd) {
if (!guildCmd.equals(commandData, true)) {
logged(`Found differences in command ${module.name}`);
guildCmd
.edit(commandData)
.then(
log(
`${module.name} updated with new data successfully!`,
),
)
.catch(c);
continue;
}
continue;
}
guild.commands
.create(commandData)
.then(log("Guild Command created", module.name, guild.name))
.catch(c);
}
return controller.next();
} catch (e) {
logged("Command did not register" + module.name);
logged(e);
return controller.stop();
}
});
}
export function optionsTransformer(ops: Array<SernOptionsData>) {
return ops.map((el) => {
switch (el.type) {
case ApplicationCommandOptionType.String:
case ApplicationCommandOptionType.Number:
case ApplicationCommandOptionType.Integer: {
return el.autocomplete && "command" in el
? (({ command, ...el }) => el)(el)
: el;
}
default:
return el;
}
});
}
export type NonEmptyArray<T extends `${number}` = `${number}`> = [T, ...T[]];
export interface ValidPublishOptions {
guildIds: string[];
dmPermission: boolean;
defaultMemberPermissions: PermissionResolvable;
}
interface GuildPublishOptions {
guildIds?: NonEmptyArray;
defaultMemberPermissions?: PermissionResolvable;
dmPermission?: never;
}
interface GlobalPublishOptions {
defaultMemberPermissions?: PermissionResolvable;
dmPermission?: false;
guildIds?: never;
}
type BasePublishOptions = GuildPublishOptions | GlobalPublishOptions;
export type PublishOptions = BasePublishOptions &
(
| Required<Pick<BasePublishOptions, "defaultMemberPermissions">>
| (
| Required<Pick<BasePublishOptions, "dmPermission">>
| Required<Pick<BasePublishOptions, "guildIds">>
)
);

View File

@@ -0,0 +1,95 @@
/**
* This is perm check, it allows users to parse the permission you want and let the plugin do the rest. (check bot or user for that perm).
*
* @author @Benzo-Fury [<@762918086349029386>]
* @author @needhamgary [<@342314924804014081>]
* @version 1.2.0
* @example
* ```ts
* import { requirePermission } from "../plugins/myPermCheck";
* import { commandModule, CommandType } from "@sern/handler";
* export default commandModule({
* plugins: [ requirePermission<CommandType>('target', 'permission', 'No response (optional)') ],
* execute: (ctx) => {
* //your code here
* }
* })
* ```
*/
import type { GuildMember, PermissionResolvable } from "discord.js";
import {
CommandType, CommandControlPlugin, controller,
} from "@sern/handler";
function payload(resp?: string) {
return {
fetchReply: true,
content: resp,
allowedMentions: { repliedUser: false },
} as const;
}
export function requirePermission(
target: "user" | "bot" | "both",
perm: PermissionResolvable[],
response?: string
) {
return CommandControlPlugin<CommandType.Both>(async (ctx, _) => {
if (ctx.guild === null) {
ctx.reply(payload("This command cannot be used here"));
console.warn(
"PermCheck > A command stopped because we couldn't check a users permissions (was used in dms)"
); //delete this line if you dont want to be notified when a command is used outside of a guild/server
return controller.stop();
}
const bot = (await ctx.guild.members.fetchMe({
cache: false,
})!) as GuildMember; const memm = ctx.member! as GuildMember;
switch (target) {
//*********************************************************************************************************************//
case "bot":
if (!bot.permissions.has(perm)) {
if (!response)
response = `I cannot use this command, please give me \`${perm.join(
", "
)}\` permission(s).`;
await ctx.reply(payload(response));
return controller.stop();
}
return controller.next();
//*********************************************************************************************************************//
case "user":
if (!memm.permissions.has(perm)) {
if (!response)
response = `You cannot use this command because you are missing \`${perm.join(
", "
)}\` permission(s).`;
await ctx.reply(payload(response));
return controller.stop();
}
return controller.next();
//*********************************************************************************************************************//
case "both":
if (
!bot.permissions.has(perm) ||
!memm.permissions.has(perm)
) {
if (!response)
response = `Please ensure <@${bot.user.id}> and <@${
memm.user.id
}> both have \`${perm.join(", ")}\` permission(s).`;
await ctx.reply(payload(response));
return controller.stop();
}
return controller.next();
//*********************************************************************************************************************//
default:
console.warn(
"Perm Check >>> You didn't specify user or bot."
);
ctx.reply(payload("User or Bot was not specified."));
return controller.stop();
}
});
}

View File

@@ -0,0 +1,38 @@
/**
* Checks if a command is available in a specific server.
*
* @author @Peter-MJ-Parker [<@1017182455926624316>]
* @version 1.0.0
* @example
* ```ts
* import { commandModule, CommandType } from "@sern/handler";
* import { serverOnly } from "../plugins/serverOnly";
* export default commandModule({
* type: CommandType.Both,
* plugins: [serverOnly(["guildId"], failMessage)], // fail message is the message you will see when the command is ran in the wrong server.
* description: "command description",
* execute: async (ctx, args) => {
* // your code here
* },
* });
* ```
*/
import { CommandType, controller, CommandControlPlugin } from "@sern/handler";
export function serverOnly(
guildId: string[],
failMessage = "This command is not available in this guild. \nFor permission to use in your server, please contact my developer."
) {
return CommandControlPlugin<CommandType.Both>(async (ctx, _) => {
if (!guildId.includes(ctx.guildId!)) {
ctx.reply(failMessage).then(async (m) => {
setTimeout(async () => {
await m.delete();
}, 3000);
});
return controller.stop();
}
return controller.next();
})
}

34
bot/src/presence.ts Normal file
View File

@@ -0,0 +1,34 @@
import { Presence } from '@sern/handler'
import { ActivityType, ClientPresenceStatus } from 'discord.js';
function shuffleArray<T>(array: T[]) {
for (let i = array.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return [...array];
}
const statuses = [[ActivityType.Watching, "the sern community", "online"],
[ActivityType.Listening, "Evo", "dnd"],
[ActivityType.Playing, "with @sern/cli", "idle"],
[ActivityType.Watching, "sern bots", "dnd"],
[ActivityType.Watching, "github stars go brrr", "online"],
[ActivityType.Listening, "Spotify", "dnd"],
[ActivityType.Listening, "what's bofa", "idle"]] satisfies
[ActivityType, string, ClientPresenceStatus][];
export default Presence.module({
execute: () => {
const [type, name, status] = statuses.at(0)!;
return Presence
.of({ activities: [ { type, name } ], status }) //start your presence with this.
.repeated(() => {
const [type, name, status] = [...shuffleArray(statuses)].shift()!;
return {
status,
activities: [{ type, name }]
};
}, 60_000); //repeat and setPresence with returned result every minute
}
})

View File

@@ -0,0 +1,9 @@
import { scheduledTask } from "@sern/handler";
export default scheduledTask({
trigger: "* * * * *",
execute: (args, { deps }) => {
console.log("hello")
}
})

3
bot/tsconfig.json Normal file
View File

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

3614
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -1,7 +1,7 @@
{
"name": "@sern/handler",
"packageManager": "yarn@3.5.0",
"version": "4.1.0",
"version": "4.2.4",
"description": "A complete, customizable, typesafe, & reactive framework for discord bots.",
"main": "./dist/index.js",
"module": "./dist/index.js",
@@ -20,6 +20,7 @@
"prepare": "tsc",
"pretty": "prettier --write .",
"tdd": "vitest",
"benchmark": "vitest bench",
"test": "vitest --run",
"analyze-imports": "npx depcruise src --include-only \"^src\" --output-type dot | dot -T svg > dependency-graph.svg"
},
@@ -35,11 +36,10 @@
"author": "SernDevs",
"license": "MIT",
"dependencies": {
"@sern/ioc": "^1.1.0",
"@sern/ioc": "^1.1.2",
"callsites": "^3.1.0",
"cron": "^3.1.7",
"deepmerge": "^4.3.1",
"rxjs": "^7.8.0"
"deepmerge": "^4.3.1"
},
"devDependencies": {
"@faker-js/faker": "^8.0.1",
@@ -47,7 +47,7 @@
"@types/node-cron": "^3.0.11",
"@typescript-eslint/eslint-plugin": "5.58.0",
"@typescript-eslint/parser": "5.59.1",
"discord.js": "^14.15.3",
"discord.js": "^14.14.1",
"eslint": "8.39.0",
"typescript": "5.0.2",
"vitest": "^1.6.0"

111
src/cleanup.ts Normal file
View File

@@ -0,0 +1,111 @@
// It's this package but without default console log / error https://github.com/trevorr/async-cleanup
/** A possibly asynchronous function invoked with the process is about to exit. */
export type CleanupListener = () => void | Promise<void>;
let cleanupListeners: Set<CleanupListener> | undefined;
/** Registers a new cleanup listener. Adding the same listener more than once has no effect. */
export function addCleanupListener(listener: CleanupListener): void {
// Install exit listeners on initial cleanup listener
if (!cleanupListeners) {
installExitListeners();
cleanupListeners = new Set();
}
cleanupListeners.add(listener);
}
/** Removes an existing cleanup listener, and returns whether the listener was registered. */
export function removeCleanupListener(listener: CleanupListener): boolean {
return cleanupListeners != null && cleanupListeners.delete(listener);
}
/** Executes all cleanup listeners and then exits the process. Call this instead of `process.exit` to ensure all listeners are fully executed. */
export async function exitAfterCleanup(code = 0): Promise<never> {
await executeCleanupListeners();
process.exit(code);
}
/** Executes all cleanup listeners and then kills the process with the given signal. */
export async function killAfterCleanup(signal: ExitSignal): Promise<void> {
await executeCleanupListeners();
process.kill(process.pid, signal);
}
async function executeCleanupListeners(): Promise<void> {
if (cleanupListeners) {
// Remove exit listeners to restore normal event handling
uninstallExitListeners();
// Clear cleanup listeners to reset state for testing
const listeners = cleanupListeners;
cleanupListeners = undefined;
// Call listeners in order added with async listeners running concurrently
const promises: Promise<void>[] = [];
for (const listener of listeners) {
try {
const promise = listener();
if (promise) promises.push(promise);
} catch (err) {
// console.error("Uncaught exception during cleanup", err);
}
}
// Wait for all listeners to complete and log any rejections
const results = await Promise.allSettled(promises);
for (const result of results) {
if (result.status === "rejected") {
console.error("Unhandled rejection during cleanup", result.reason);
}
}
}
}
function beforeExitListener(code: number): void {
// console.log(`Exiting with code ${code} due to empty event loop`);
void exitAfterCleanup(code);
}
function uncaughtExceptionListener(error: Error): void {
// console.error("Exiting with code 1 due to uncaught exception", error);
void exitAfterCleanup(1);
}
function signalListener(signal: ExitSignal): void {
// console.log(`Exiting due to signal ${signal}`);
void killAfterCleanup(signal);
}
// Listenable signals that terminate the process by default
// (except SIGQUIT, which generates a core dump and should not trigger cleanup)
// See https://nodejs.org/api/process.html#signal-events
const listenedSignals = [
"SIGBREAK", // Ctrl-Break on Windows
"SIGHUP", // Parent terminal closed
"SIGINT", // Terminal interrupt, usually by Ctrl-C
"SIGTERM", // Graceful termination
"SIGUSR2", // Used by Nodemon
] as const;
/** Signals that can terminate the process. */
export type ExitSignal =
| typeof listenedSignals[number]
| "SIGKILL"
| "SIGQUIT"
| "SIGSTOP";
function installExitListeners(): void {
process.on("beforeExit", beforeExitListener);
process.on("uncaughtException", uncaughtExceptionListener);
listenedSignals.forEach((signal) => process.on(signal, signalListener));
}
function uninstallExitListeners(): void {
process.removeListener("beforeExit", beforeExitListener);
process.removeListener("uncaughtException", uncaughtExceptionListener);
listenedSignals.forEach((signal) =>
process.removeListener(signal, signalListener)
);
}

View File

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

View File

@@ -70,7 +70,7 @@ export async function makeDependencies (conf: ValidDependencyConfig) {
}
container.addSingleton('@sern/errors', new __Services.DefaultErrorHandling);
container.addSingleton('@sern/modules', new Map);
container.addSingleton('@sern/emitter', new EventEmitter)
container.addSingleton('@sern/emitter', new EventEmitter({ captureRejections: true }))
container.addSingleton('@sern/scheduler', new __Services.TaskScheduler)
conf(dependencyBuilder(container));
await container.ready();

View File

@@ -102,5 +102,34 @@ export function discordEvent<T extends keyof ClientEvents>(mod: {
return eventModule({ type: EventType.Discord, ...mod, });
}
export function scheduledTask(ism: ScheduledTask) { return ism }
/**
* Creates a scheduled task that can be executed at specified intervals using cron patterns
*
* @param {ScheduledTask} ism - The scheduled task configuration object
* @param {string} ism.trigger - A cron pattern that determines when the task should execute
* Format: "* * * * *" (minute hour day month day-of-week)
* @param {Function} ism.execute - The function to execute when the task is triggered
* @param {Object} ism.execute.context - The execution context passed to the task
*
* @returns {ScheduledTask} The configured scheduled task
*
* @example
* // Create a task that runs every minute
* export default scheduledTask({
* trigger: "* * * * *",
* execute: (context) => {
* console.log("Task executed!");
* }
* });
*
* @remarks
* - Tasks must be placed in the 'tasks' directory specified in your config
* - The file name serves as a unique identifier for the task
* - Tasks can be cancelled using deps['@sern/scheduler'].kill(uuid)
*
* @see {@link https://crontab.guru/} for testing and creating cron patterns
*/
export function scheduledTask(ism: ScheduledTask): ScheduledTask {
return ism
}

View File

@@ -15,16 +15,107 @@ export function makePlugin<V extends unknown[]>(
export function EventInitPlugin(execute: (args: InitArgs) => PluginResult) {
return makePlugin(PluginType.Init, execute);
}
/**
* Creates an initialization plugin for command preprocessing and modification
*
* @since 2.5.0
* @template I - Extends CommandType to enforce type safety for command modules
*
* @param {function} execute - Function to execute during command initialization
* @param {InitArgs<T>} execute.args - The initialization arguments
* @param {T} execute.args.module - The command module being initialized
* @param {string} execute.args.absPath - The absolute path to the module file
* @param {Dependencies} execute.args.deps - Dependency injection container
*
* @returns {Plugin} A plugin that runs during command initialization
*
* @example
* // Plugin to update command description
* export const updateDescription = (description: string) => {
* return CommandInitPlugin(({ deps }) => {
* if(description.length > 100) {
* deps.logger?.info({ message: "Invalid description" })
* return controller.stop("From updateDescription: description is invalid");
* }
* module.description = description;
* return controller.next();
* });
* };
*
* @example
* // Plugin to store registration date in module locals
* export const dateRegistered = () => {
* return CommandInitPlugin(({ module }) => {
* module.locals.registered = Date.now()
* return controller.next();
* });
* };
*
* @remarks
* - Init plugins can modify how commands are loaded and perform preprocessing
* - The module.locals object can be used to store custom plugin-specific data
* - Be careful when modifying module fields as multiple plugins may interact with them
* - Use controller.next() to continue to the next plugin
* - Use controller.stop(reason) to halt plugin execution
*/
export function CommandInitPlugin<I extends CommandType>(
execute: (args: InitArgs) => PluginResult
) {
): Plugin {
return makePlugin(PluginType.Init, execute);
}
/**
* Creates a control plugin for command preprocessing, filtering, and state management
*
* @since 2.5.0
* @template I - Extends CommandType to enforce type safety for command modules
*
* @param {function} execute - Function to execute during command control flow
* @param {CommandArgs<I>} execute.args - The command arguments array
* @param {Context} execute.args[0] - The discord context (e.g., guild, channel, user info, interaction)
* @param {SDT} execute.args[1] - The State, Dependencies, Params, Module, and Type object
*
* @returns {Plugin} A plugin that runs during command execution flow
*
* @example
* // Plugin to restrict command to specific guild
* export const inGuild = (guildId: string) => {
* return CommandControlPlugin((ctx, sdt) => {
* if(ctx.guild.id !== guildId) {
* return controller.stop();
* }
* return controller.next();
* });
* };
*
* @example
* // Plugins passing state through the chain
* const plugin1 = CommandControlPlugin((ctx, sdt) => {
* return controller.next({ 'plugin1/data': 'from plugin1' });
* });
*
* const plugin2 = CommandControlPlugin((ctx, sdt) => {
* return controller.next({ 'plugin2/data': ctx.user.id });
* });
*
* export default commandModule({
* type: CommandType.Slash,
* plugins: [plugin1, plugin2],
* execute: (ctx, sdt) => {
* console.log(sdt.state); // Access accumulated state
* }
* });
*
* @remarks
* - Control plugins are executed in order when a discord.js event is emitted
* - Use controller.next() to continue to next plugin or controller.stop() to halt execution
* - State can be passed between plugins using controller.next({ key: value })
* - State keys should be namespaced to avoid collisions (e.g., 'plugin-name/key')
* - Final accumulated state is passed to the command's execute function
* - All plugins must succeed for the command to execute
* - Plugins have access to dependencies through the sdt.deps object
* - Useful for implementing preconditions, filters, and command preprocessing
*/
export function CommandControlPlugin<I extends CommandType>(
execute: (...args: CommandArgs<I>) => PluginResult,

View File

@@ -1,219 +1,18 @@
import type { Interaction, Message, BaseInteraction } from 'discord.js';
import util from 'node:util';
import {
EMPTY, type Observable, concatMap, filter,
throwError, fromEvent, map, type OperatorFunction,
catchError, finalize, pipe, from, take, share, of,
} from 'rxjs';
import * as Id from '../core/id'
import type { Emitter, ErrorHandling, Logging } from '../core/interfaces';
import type { Emitter, Logging } from '../core/interfaces';
import { SernError } from '../core/structures/enums'
import { EMPTY_ERR, Err, Ok, Result, wrapAsync } from '../core/structures/result';
import type { UnpackedDependencies } from '../types/utility';
import type { CommandModule, Module, Processed } from '../types/core-modules';
import * as assert from 'node:assert';
import { Context } from '../core/structures/context';
import { CommandType } from '../core/structures/enums'
import { Ok, wrapAsync} from '../core/structures/result';
import type { Module } from '../types/core-modules';
import { inspect } from 'node:util'
import { disposeAll } from '../core/ioc';
import { resultPayload, isAutocomplete, treeSearch, fmt } from '../core/functions'
import { resultPayload } from '../core/functions'
import merge from 'deepmerge'
function handleError<C>(crashHandler: ErrorHandling, emitter: Emitter, logging?: Logging) {
return (pload: unknown, caught: Observable<C>) => {
// This is done to fit the ErrorHandling contract
if(!emitter.emit('error', pload)) {
const err = pload instanceof Error ? pload : Error(util.inspect(pload, { colors: true }));
logging?.error({ message: util.inspect(pload) });
crashHandler.updateAlive(err);
}
return caught;
};
}
const arrayify= <T>(src: T) =>
Array.isArray(src) ? src : [src];
interface ExecutePayload {
module: Module;
args: unknown[];
deps: Dependencies;
params?: string;
[key: string]: unknown
}
export const filterTap = <K, R>(onErr: (e: R) => void): OperatorFunction<Result<K, R>, K> =>
concatMap(result => {
if(result.ok){
return of(result.value)
}
onErr(result.error);
return EMPTY;
})
export const sharedEventStream = <T>(e: Emitter, eventName: string) =>
(fromEvent(e, eventName) as Observable<T>).pipe(share());
function intoPayload(module: Module, deps: Dependencies) {
return pipe(map(arrayify),
map(args => ({ module, args, deps })),
map(p => p.args));
}
/**
* Creates an observable from { source }
* @param module
* @param source
*/
export function eventDispatcher(deps: Dependencies, module: Module, source: unknown) {
assert.ok(source && typeof source === 'object',
`${source} cannot be constructed into an event listener`);
const execute: OperatorFunction<unknown[]|undefined, unknown> =
concatMap(async args => {
if(args) return Reflect.apply(module.execute, null, args);
});
//@ts-ignore
let ev = fromEvent(source ,module.name!);
//@ts-ignore
if(module['once']) {
ev = ev.pipe(take(1))
}
return ev.pipe(intoPayload(module, deps),
execute);
}
interface DispatchPayload {
module: Processed<CommandModule>;
event: BaseInteraction;
defaultPrefix?: string;
deps: Dependencies;
params?: string
};
export function createDispatcher({ module, event, defaultPrefix, deps, params }: DispatchPayload): ExecutePayload {
assert.ok(CommandType.Text !== module.type,
SernError.MismatchEvent + 'Found text command in interaction stream');
if(isAutocomplete(event)) {
assert.ok(module.type === CommandType.Slash
|| module.type === CommandType.Both, "Autocomplete option on non command interaction");
const option = treeSearch(event, module.options);
assert.ok(option, SernError.NotSupportedInteraction + ` There is no autocomplete tag for ` + inspect(module));
const { command } = option;
return { module: command as Processed<Module>, //autocomplete is not a true "module" warning cast!
args: [event],
deps };
}
switch (module.type) {
case CommandType.Slash:
case CommandType.Both: {
return { module, args: [Context.wrap(event, defaultPrefix)], deps };
}
default: return { module, args: [event], deps, params };
}
}
function createGenericHandler<Source, Narrowed extends Source, Output>(
source: Observable<Source>,
makeModule: (event: Narrowed) => Promise<Output>,
) {
return (pred: (i: Source) => i is Narrowed) =>
source.pipe(
filter(pred), // only handle this stream if it passes pred
concatMap(makeModule)); // create a payload, preparing to execute
}
/**
*
* 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>,
deps: Dependencies,
defaultPrefix?: string
) {
const mg = deps['@sern/modules'];
return createGenericHandler<Interaction, T, Result<ReturnType<typeof createDispatcher>, void>>(
source,
async event => {
const possibleIds = Id.reconstruct(event);
let modules = possibleIds
.map(({ id, params }) => ({ module: mg.get(id), params }))
.filter(({ module }) => module !== undefined);
if(modules.length == 0) {
return EMPTY_ERR;
}
const [{module, params}] = modules;
return Ok(createDispatcher({
module: module as Processed<CommandModule>,
event, defaultPrefix, deps, params
}));
});
}
export function createMessageHandler(
source: Observable<Message>,
defaultPrefix: string,
deps: Dependencies,
) {
const mg = deps['@sern/modules'];
return createGenericHandler(source, async event => {
const [prefix] = fmt(event.content, defaultPrefix);
let module= mg.get(`${prefix}_T`) ?? mg.get(`${prefix}_B`) as Module;
if(!module) {
return Err('Possibly undefined behavior: could not find a static id to resolve');
}
return Ok({ args: [Context.wrap(event, defaultPrefix)], module, deps })
});
}
/**
* Wraps the task in a Result as a try / catch.
* if the task is ok, an event is emitted and the stream becomes empty
* if the task is an error, throw an error down the stream which will be handled by catchError
* thank u kingomes
* @param emitter reference to SernEmitter that will emit a successful execution of module
* @param module the module that will be executed with task
* @param task the deferred execution which will be called
*/
export function executeModule(emitter: Emitter, { module, args }: ExecutePayload) {
return from(wrapAsync(async () => module.execute(...args)))
.pipe(concatMap(result => {
if (result.ok){
emitter.emit('module.activate', resultPayload('success', module));
return EMPTY;
}
return throwError(() => resultPayload('failure', module, result.error));
}))
};
/**
* A higher order function that
* - calls all control plugins.
* - any failures results to { config.onStop } being called
* - if all results are ok, the stream is converted to { config.onNext }
* config.onNext will be returned if everything is okay.
* @param config
* @returns function which calls all plugins and returns onNext or fail
*/
export function createResultResolver<Output>(config: {
onStop?: (module: Module, err?: string) => unknown;
onNext: (args: ExecutePayload, map: Record<string, unknown>) => Output;
}) {
const { onStop, onNext } = config;
return async (payload: ExecutePayload) => {
const task = await callPlugins(payload);
if (!task) throw Error("Plugin did not return anything.");
if(!task.ok) {
onStop?.(payload.module, String(task.error));
} else {
return onNext(payload, task.value) as Output;
}
};
};
function isObject(item: unknown) {
return (item && typeof item === 'object' && !Array.isArray(item));
@@ -238,20 +37,32 @@ export async function callInitPlugins(_module: Module, deps: Dependencies, emit?
return module
}
export async function callPlugins({ args, module, deps, params }: ExecutePayload) {
export function executeModule(emitter: Emitter, logger: Logging|undefined, { module, args } : ExecutePayload) {
const moduleCalled = wrapAsync(async () => {
return module.execute(...args);
})
moduleCalled
.then((res) => {
if(res.ok) {
emitter.emit('module.activate', resultPayload('success', module))
} else {
if(!emitter.emit('error', resultPayload('failure', module, res.error))) {
// node crashes here.
logger?.error({ 'message': res.error })
}
}
})
.catch(err => {
throw err
})
};
export async function callPlugins({ args, module }: ExecutePayload) {
let state = {};
for(const plugin of module.onEvent??[]) {
const executionContext = {
state,
deps,
params,
type: module.type,
module: { name: module.name,
description: module.description,
locals: module.locals,
meta: module.meta }
};
const result = await plugin.execute(...args, executionContext);
const result = await plugin.execute(...args);
if(!result.ok) {
return result;
}
@@ -261,34 +72,3 @@ export async function callPlugins({ args, module, deps, params }: ExecutePayload
}
return Ok(state);
}
/**
* Creates an executable task ( execute the command ) if all control plugins are successful
* this needs to go
* @param onStop emits a failure response to the SernEmitter
*/
export function intoTask(onStop: (m: Module) => unknown) {
const onNext = ({ args, module, deps, params }: ExecutePayload, state: Record<string, unknown>) => {
return {
module,
args: [...args, { state,
deps,
params,
type: module.type,
module: { name: module.name,
description: module.description,
locals: module.locals,
meta: module.meta } }],
deps
}
};
return createResultResolver({ onStop, onNext });
}
export const handleCrash = ({ "@sern/errors": err, '@sern/emitter': sem, '@sern/logger': log } : UnpackedDependencies, metadata: string) =>
pipe(catchError(handleError(err, sem, log)),
finalize(() => {
log?.info({ message: 'A stream closed: ' + metadata });
disposeAll(log);
}))

View File

@@ -1,30 +1,74 @@
import type { Interaction } from 'discord.js';
import { mergeMap, merge, concatMap, EMPTY } from 'rxjs';
import { createInteractionHandler, executeModule, intoTask, sharedEventStream, filterTap, handleCrash } from './event-utils';
import type { Module, SernAutocompleteData } from '../types/core-modules'
import { callPlugins, executeModule } from './event-utils';
import { SernError } from '../core/structures/enums'
import { isAutocomplete, isCommand, isMessageComponent, isModal, resultPayload } from '../core/functions'
import { UnpackedDependencies } from '../types/utility';
import { createSDT, isAutocomplete, isCommand, isContextCommand, isMessageComponent, isModal, resultPayload } from '../core/functions'
import type { UnpackedDependencies } from '../types/utility';
import * as Id from '../core/id'
import { Context } from '../core/structures/context';
import path from 'node:path';
export default function interactionHandler(deps: UnpackedDependencies, defaultPrefix?: string) {
export function interactionHandler(deps: UnpackedDependencies, defaultPrefix?: string) {
//i wish javascript had clojure destructuring
const { '@sern/client': client,
'@sern/emitter': emitter } = deps
const interactionStream$ = sharedEventStream<Interaction>(client, 'interactionCreate');
const handle = createInteractionHandler(interactionStream$, deps, defaultPrefix);
'@sern/modules': moduleManager,
'@sern/logger': log,
'@sern/emitter': reporter } = deps
const interactionHandler$ = merge(handle(isMessageComponent),
handle(isAutocomplete),
handle(isCommand),
handle(isModal));
return interactionHandler$
.pipe(filterTap(e => emitter.emit('warning', resultPayload('warning', undefined, e))),
concatMap(intoTask(module => {
emitter.emit('module.activate', resultPayload('failure', module, SernError.PluginFailure))
})),
mergeMap(payload => {
if(payload)
return executeModule(emitter, payload)
return EMPTY;
}),
handleCrash(deps, "interaction handling"));
client.on('interactionCreate', async (event) => {
//returns array of possible ids
const possibleIds = Id.reconstruct(event);
let modules = possibleIds
.map(({ id, params }) => ({ module: moduleManager.get(id)!, params }))
.filter(({ module }) => module !== undefined);
if(modules.length == 0) {
return;
}
const { module, params } = modules.at(0)!;
let payload;
// handles autocomplete
if(isAutocomplete(event)) {
const lookupTable = module.locals['@sern/lookup-table'] as Map<string, SernAutocompleteData>
const subCommandGroup = event.options.getSubcommandGroup(false) ?? "",
subCommand = event.options.getSubcommand(false) ?? "",
option = event.options.getFocused(true),
fullPath = path.posix.join("<parent>", subCommandGroup, subCommand, option.name)
const resolvedModule = (lookupTable.get(fullPath)!.command) as Module
payload= { module: resolvedModule , //autocomplete is not a true "module" warning cast!
args: [event, createSDT(module, deps, params)] };
// either CommandTypes Slash | ContextMessage | ContextUesr
} else if(isCommand(event)) {
const sdt = createSDT(module, deps, params)
// handle CommandType.CtxUser || CommandType.CtxMsg
if(isContextCommand(event)) {
payload= { module, args: [event, sdt] };
} else {
// handle CommandType.Slash || CommandType.Both
payload= { module, args: [Context.wrap(event, defaultPrefix), sdt] };
}
// handles modals or components
} else if (isModal(event) || isMessageComponent(event)) {
payload= { module, args: [event, createSDT(module, deps, params)] }
} else {
throw Error("Unknown interaction while handling in interactionCreate event " + event)
}
const result = await callPlugins(payload)
if(!result.ok) {
reporter.emit('module.activate', resultPayload('failure', module, result.error ?? SernError.PluginFailure))
return
}
if(payload.args.length !== 2) {
throw Error ('Invalid payload')
}
//@ts-ignore assigning final state from plugin
payload.args[1].state = result.value
// note: do not await this. will be blocking if long task (ie waiting for modal input)
executeModule(reporter, log, payload);
});
}

View File

@@ -1,17 +1,17 @@
import { EMPTY, mergeMap, concatMap } from 'rxjs';
import type { Message } from 'discord.js';
import { createMessageHandler, executeModule, intoTask, sharedEventStream, filterTap, handleCrash} from './event-utils';
import { callPlugins, executeModule } from './event-utils';
import { SernError } from '../core/structures/enums'
import { resultPayload } from '../core/functions'
import { UnpackedDependencies } from '../types/utility';
import type { Emitter } from '../core/interfaces';
import { createSDT, fmt, resultPayload } from '../core/functions'
import type { UnpackedDependencies } from '../types/utility';
import type { Module } from '../types/core-modules';
import { Context } from '../core/structures/context';
/**
* Ignores messages from any person / bot except itself
* @param prefix
*/
function isNonBot(prefix: string) {
return (msg: Message): msg is Message => !msg.author.bot && hasPrefix(prefix, msg.content);
function isBotOrNoPrefix(msg: Message, prefix: string) {
return msg.author.bot || !hasPrefix(prefix, msg.content);
}
function hasPrefix(prefix: string, content: string) {
@@ -19,32 +19,36 @@ function hasPrefix(prefix: string, content: string) {
return prefixInContent.localeCompare(prefix, undefined, { sensitivity: 'accent' }) === 0;
}
export default
function (deps: UnpackedDependencies, defaultPrefix?: string) {
export function messageHandler (deps: UnpackedDependencies, defaultPrefix?: string) {
const {"@sern/emitter": emitter,
'@sern/logger': log,
'@sern/modules': mg,
'@sern/client': client} = deps
if (!defaultPrefix) {
log?.debug({ message: 'No prefix found. message handler shutting down' });
return EMPTY;
return;
}
const messageStream$ = sharedEventStream<Message>(client as unknown as Emitter, 'messageCreate');
const handle = createMessageHandler(messageStream$, defaultPrefix, deps);
client.on('messageCreate', async message => {
if(isBotOrNoPrefix(message, defaultPrefix)) {
return
}
const [prefix] = fmt(message.content, defaultPrefix);
let module = mg.get(`${prefix}_T`) ?? mg.get(`${prefix}_B`) as Module;
if(!module) {
throw Error('Possibly undefined behavior: could not find a static id to resolve')
}
const payload = { module, args: [Context.wrap(message, defaultPrefix), createSDT(module, deps, undefined)] }
const result = await callPlugins(payload)
if (!result.ok) {
emitter.emit('module.activate', resultPayload('failure', module, result.error ?? SernError.PluginFailure))
return
}
const msgCommands$ = handle(isNonBot(defaultPrefix));
//@ts-ignore
payload.args[1].state = result.value
return msgCommands$.pipe(
filterTap(e => emitter.emit('warning', resultPayload('warning', undefined, e))),
concatMap(intoTask(module => {
const result = resultPayload('failure', module, SernError.PluginFailure);
emitter.emit('module.activate', result);
})),
mergeMap(payload => {
if(payload)
return executeModule(emitter, payload)
return EMPTY;
}),
handleCrash(deps, "message handling")
)
executeModule(emitter, log, payload)
})
}

View File

@@ -1,40 +1,99 @@
import { concatMap, from, interval, of, map, startWith, fromEvent, take, mergeScan } from "rxjs"
import { Presence } from "../core/presences";
import { Services } from "../core/ioc";
import assert from "node:assert";
import * as Files from "../core/module-loading";
type SetPresence = (conf: Presence.Result) => Promise<unknown>
const parseConfig = async (conf: Promise<Presence.Result>) => {
return conf.then(s => {
if('repeat' in s) {
const { onRepeat, repeat } = s;
assert(repeat !== undefined, "repeat option is undefined");
assert(onRepeat !== undefined, "onRepeat callback is undefined, but repeat exists");
const src$ = typeof repeat === 'number'
? interval(repeat)
: fromEvent(...repeat);
return src$.pipe(mergeScan(async (args) => onRepeat(args), s),
startWith(s));
const parseConfig = async (conf: Promise<Presence.Result>, setPresence: SetPresence) => {
const result = await conf;
if ('repeat' in result) {
const { onRepeat, repeat } = result;
// Validate configuration
if (repeat === undefined) {
throw new Error("repeat option is undefined");
}
return of(s).pipe(take(1));
})
if (onRepeat === undefined) {
throw new Error("onRepeat callback is undefined, but repeat exists");
}
// Initial state
let currentState = result;
const processState = async (state: typeof currentState) => {
try {
const result = onRepeat(state);
// If it's a promise, await it, otherwise use the value directly
return result instanceof Promise ? await result : result;
} catch (error) {
// TODO process error
//console.error(error);
return state; // Return previous state on error
}
};
// Handle numeric interval
if (typeof repeat === 'number') {
// Return a promise that never resolves (or resolves on cleanup)
return new Promise((resolve) => {
// Immediately return initial state
processState(currentState);
// Set up interval
let isProcessing = false;
const intervalId = setInterval(() => {
// Skip if previous operation is still running
if (isProcessing) return;
isProcessing = true;
processState(currentState)
.then(newState => {
currentState = newState;
return setPresence(currentState)
})
.catch(console.error)
.finally(() => {
isProcessing = false;
});
}, repeat);
// Optional: Return cleanup function
return () => clearInterval(intervalId);
});
}
// Handle event-based repeat
else {
const handler = async () => {
currentState = await onRepeat(currentState);
await setPresence(currentState);
};
let has_registered = false;
return new Promise((resolve) => {
const [target, eventName] = repeat;
// Immediately return initial state
processState(currentState);
// Set up event listener
if(!has_registered) {
target.addListener(eventName, handler);
has_registered=true;
}
// Optional: Return cleanup function
return () => target.removeListener(eventName, handler);
});
}
}
// No repeat configuration, just return the result
return setPresence(result);
};
export const presenceHandler = (path: string, setPresence: SetPresence) => {
const presence = Files
.importModule<Presence.Config<(keyof Dependencies)[]>>(path)
.then(({ module }) => {
//fetch services with the order preserved, passing it to the execute fn
const fetchedServices = Services(...module.inject ?? []);
return async () => module.execute(...fetchedServices);
})
const module$ = from(presence);
return module$.pipe(
//compose:
//call the execute function, passing that result into parseConfig.
//concatMap resolves the promise, and passes it to the next concatMap.
concatMap(fn => parseConfig(fn())),
// subscribe to the observable parseConfig yields, and set the presence.
concatMap(conf => conf.pipe(map(setPresence))));
export const presenceHandler = async (path: string, setPresence: SetPresence) => {
const presence = await
Files.importModule<Presence.Config<(keyof Dependencies)[]>>(path)
.then(({ module }) => {
//fetch services with the order preserved, passing it to the execute fn
const fetchedServices = Services(...module.inject ?? []);
return async () => module.execute(...fetchedServices);
})
return parseConfig(presence(), setPresence);
}

View File

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

View File

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

View File

@@ -1,33 +1,62 @@
import { EventType, SernError } from '../core/structures/enums';
import { callInitPlugins, eventDispatcher, handleCrash } from './event-utils'
import { EventModule, Module } from '../types/core-modules';
import { callInitPlugins } from './event-utils'
import { EventModule } from '../types/core-modules';
import * as Files from '../core/module-loading'
import type { UnpackedDependencies } from '../types/utility';
import { from, map, mergeAll } from 'rxjs';
import type { Emitter } from '../core/interfaces';
import { inspect } from 'util'
import { resultPayload } from '../core/functions';
import type { Wrapper } from '../'
export default async function(deps: UnpackedDependencies, wrapper: Wrapper) {
const eventModules: EventModule[] = [];
const eventDirs = Array.isArray(wrapper.events!) ? wrapper.events! : [wrapper.events!];
for (const dir of eventDirs) {
for await (const path of Files.readRecursive(dir)) {
let { module } = await Files.importModule<EventModule>(path);
await callInitPlugins(module, deps)
eventModules.push(module);
}
}
const logger = deps['@sern/logger'], report = deps['@sern/emitter'];
for (const module of eventModules) {
let source: Emitter;
const intoDispatcher = (deps: UnpackedDependencies) =>
(module : EventModule) => {
switch (module.type) {
case EventType.Sern:
return eventDispatcher(deps, module, deps['@sern/emitter']);
source=deps['@sern/emitter'];
break
case EventType.Discord:
return eventDispatcher(deps, module, deps['@sern/client']);
source=deps['@sern/client'];
break
case EventType.External:
return eventDispatcher(deps, module, deps[module.emitter]);
source=deps[module.emitter] as Emitter;
break
default: throw Error(SernError.InvalidModuleType + ' while creating event handler');
}
};
export default async function(deps: UnpackedDependencies, eventDir: string) {
const eventModules: EventModule[] = [];
for await (const path of Files.readRecursive(eventDir)) {
let { module } = await Files.importModule<Module>(path);
await callInitPlugins(module, deps)
eventModules.push(module as EventModule);
if(!source && typeof source !== 'object') {
throw Error(`${source} cannot be constructed into an event listener`)
}
if(!('addListener' in source && 'removeListener' in source)) {
throw Error('source must implement Emitter')
}
const execute = async (...args: any[]) => {
try {
if(args) {
if('once' in module) { source.removeListener(String(module.name!), execute); }
await Reflect.apply(module.execute, null, args);
}
} catch(e) {
const err = e instanceof Error ? e : Error(inspect(e, { colors: true }));
if(!report.emit('error', resultPayload('failure', module, err))) {
logger?.error({ message: inspect(err) });
}
}
}
source.addListener(String(module.name!), execute)
}
from(eventModules)
.pipe(map(intoDispatcher(deps)),
mergeAll(), // all eventListeners are turned on
handleCrash(deps, "event modules"))
.subscribe();
}

View File

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

View File

@@ -1,24 +1,22 @@
//side effect: global container
import { useContainerRaw } from '@sern/ioc/global';
// set asynchronous capturing of errors
import events from 'node:events'
events.captureRejections = true;
import callsites from 'callsites';
import * as Files from './core/module-loading';
import { merge } from 'rxjs';
import eventsHandler from './handlers/user-defined-events';
import ready from './handlers/ready';
import messageHandler from './handlers/message';
import interactionHandler from './handlers/interaction';
import { interactionHandler } from './handlers/interaction';
import { messageHandler } from './handlers/message'
import { presenceHandler } from './handlers/presence';
import { UnpackedDependencies } from './types/utility';
import type { Payload, UnpackedDependencies, Wrapper } from './types/utility';
import type { Presence} from './core/presences';
import { registerTasks } from './handlers/tasks';
import { addCleanupListener } from './cleanup';
interface Wrapper {
commands: string;
defaultPrefix?: string;
events?: string;
tasks?: string;
}
/**
* @since 1.0.0
* @param maybeWrapper Options to pass into sern.
@@ -35,9 +33,8 @@ interface Wrapper {
export function init(maybeWrapper: Wrapper = { commands: "./dist/commands" }) {
const startTime = performance.now();
const deps = useContainerRaw().deps<UnpackedDependencies>();
if (maybeWrapper.events !== undefined) {
eventsHandler(deps, maybeWrapper.events)
eventsHandler(deps, maybeWrapper)
.then(() => {
deps['@sern/logger']?.info({ message: "Events registered" });
});
@@ -45,6 +42,22 @@ export function init(maybeWrapper: Wrapper = { commands: "./dist/commands" }) {
deps['@sern/logger']?.info({ message: "No events registered" });
}
// autohandle errors that occur in modules.
// convenient for rapid iteration
if(maybeWrapper.handleModuleErrors) {
if(!deps['@sern/logger']) {
throw Error('A logger is required to handleModuleErrors.\n A default logger is already supplied!');
}
deps['@sern/logger']?.info({ 'message': 'handleModuleErrors enabled' })
deps['@sern/emitter'].addListener('error', (payload: Payload) => {
if(payload.type === 'failure') {
deps['@sern/logger']?.error({ message: payload.reason })
} else {
deps['@sern/logger']?.warning({ message: "error event should only have payloads of 'failure'" });
}
})
}
const initCallsite = callsites()[1].getFileName();
const presencePath = Files.shouldHandle(initCallsite!, "presence");
//Ready event: load all modules and when finished, time should be taken and logged
@@ -56,16 +69,20 @@ export function init(maybeWrapper: Wrapper = { commands: "./dist/commands" }) {
const setPresence = async (p: Presence.Result) => {
return deps['@sern/client'].user?.setPresence(p);
}
presenceHandler(presencePath.path, setPresence).subscribe();
presenceHandler(presencePath.path, setPresence);
}
if(maybeWrapper.tasks) {
registerTasks(maybeWrapper.tasks, deps);
}
})
.catch(err => { throw err });
interactionHandler(deps, maybeWrapper.defaultPrefix);
messageHandler(deps, maybeWrapper.defaultPrefix);
const messages$ = messageHandler(deps, maybeWrapper.defaultPrefix);
const interactions$ = interactionHandler(deps, maybeWrapper.defaultPrefix);
// listening to the message stream and interaction stream
merge(messages$, interactions$).subscribe();
addCleanupListener(async () => {
const duration = ((performance.now() - startTime) / 1000).toFixed(2)
deps['@sern/logger']?.info({ 'message': 'sern is shutting down after '+duration +" seconds" })
await useContainerRaw().disposeAll();
});
}

View File

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

View File

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

84
test/autocomp.bench.ts Normal file
View File

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

View File

@@ -1,72 +1,17 @@
//@ts-nocheck
import { afterEach, describe, expect, it, vi } from 'vitest';
import { PluginType, SernOptionsData, controller } from '../../src/index';
import { partitionPlugins, treeSearch } from '../../src/core/functions';
import { createLookupTable, partitionPlugins, treeSearch } from '../../src/core/functions';
import { faker } from '@faker-js/faker';
import { ApplicationCommandOptionType, AutocompleteInteraction } from 'discord.js';
vi.mock('discord.js', async (importOriginal) => {
const mod = await importOriginal()
const ModalSubmitInteraction = class {
customId;
type = 5;
isModalSubmit = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const ButtonInteraction = class {
customId;
type = 3;
componentType = 2;
isButton = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const AutocompleteInteraction = class {
type = 4;
option: string;
constructor(s: string) {
this.option = s;
}
options = {
getFocused: vi.fn(),
getSubcommand: vi.fn(),
};
};
return {
Collection: mod.Collection,
ComponentType: mod.ComponentType,
InteractionType: mod.InteractionType,
ApplicationCommandOptionType: mod.ApplicationCommandOptionType,
ApplicationCommandType: mod.ApplicationCommandType,
ModalSubmitInteraction,
ButtonInteraction,
AutocompleteInteraction,
};
});
import { createRandomChoice, createRandomPlugins } from '../setup/util';
describe('functions', () => {
afterEach(() => {
vi.clearAllMocks();
});
function createRandomPlugins(len: number) {
const random = () => Math.floor(Math.random() * 2) + 1; // 1 or 2, plugin enum
return Array.from({ length: len }, () => ({
type: random(),
execute: () => (random() === 1 ? controller.next() : controller.stop()),
}));
}
function createRandomChoice() {
return {
type: faker.number.int({ min: 1, max: 11 }),
name: faker.word.noun(),
description: faker.word.adjective(),
};
}
it('should partition plugins correctly', () => {
const plugins = createRandomPlugins(100);
const [onEvent, init] = partitionPlugins(plugins);
@@ -75,308 +20,275 @@ describe('functions', () => {
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: vi.fn() },
},
];
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'autocomplete',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('autocomplete');
expect(result.command).to.be.not.undefined;
}),
it('should tree search depth 2', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('nested');
describe('autocomplete', ( ) => {
it('should tree search options tree depth 1', () => {
const options: SernOptionsData[] = [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'autocomplete',
description: 'here',
autocomplete: true,
command: { onEvent: [], execute: vi.fn() },
},
];
const table = createLookupTable(options)
const result = table.get('<parent>/autocomplete')
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', () => {
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${subcommandName}/nested`)
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', () => {
const subgroupName = faker.string.alpha()
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
type: ApplicationCommandOptionType.SubcommandGroup,
name: subgroupName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
createRandomChoice(),
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
const table = createLookupTable(options)
const result = table.get(`<parent>/${subgroupName}/${subcommandName}/nested`)
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('should tree search depth n > 2', () => {
//@ts-expect-error mocking
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
it('should correctly resolve suboption of the same name given two subcommands ', () => {
const subcommandName = faker.string.alpha();
const groupname = faker.string.alpha()
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: groupname,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
},
createRandomChoice(),
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${groupname}/${subcommandName}/nested`);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
const result = treeSearch(autocmpInteraction, options);
expect(result == undefined).to.be.false;
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('should correctly resolve suboption of the same name given two subcommands ', () => {
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
it('two subcommands with an option of the same name', () => {
const groupName = faker.string.alpha()
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: groupName,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'anothera',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
},
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
});
it('two subcommands with an option of the same name', () => {
let autocmpInteraction = new AutocompleteInteraction('nested');
const subcommandName = faker.string.alpha();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: 'nested',
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: () => {},
},
},
],
},
],
},
];
autocmpInteraction.options.getSubcommand.mockReturnValue(subcommandName);
autocmpInteraction.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result = treeSearch(autocmpInteraction, options);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
],
},
],
},
];
const table = createLookupTable(options)
const result = table.get(`<parent>/${groupName}/${subcommandName}/nested`);
expect(result).toBeTruthy();
expect(result.name).to.be.eq('nested');
expect(result.command).to.be.not.undefined;
let autocmpInteraction2 = new AutocompleteInteraction('nested');
autocmpInteraction2.options.getSubcommand.mockReturnValue(subcommandName + 'a');
autocmpInteraction2.options.getFocused.mockReturnValue({
name: 'nested',
value: faker.string.alpha(),
focused: true,
});
const result2 = treeSearch(autocmpInteraction2, options);
expect(result2).toBeTruthy();
expect(result2?.name).toEqual('nested');
});
it('simulates autocomplete typing and resolution', () => {
const subcommandName = faker.string.alpha();
const optionName = faker.word.noun();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: faker.string.alpha(),
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
it('simulates autocomplete typing and resolution', () => {
const subcommandGroupName = faker.string.alpha()
const subcommandName = faker.string.alpha();
const optionName = faker.word.noun();
const options: SernOptionsData[] = [
{
type: ApplicationCommandOptionType.SubcommandGroup,
name: subcommandGroupName,
description: faker.string.alpha(),
options: [
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName,
description: faker.string.alpha(),
options: [
createRandomChoice(),
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
},
},
},
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
],
},
{
type: ApplicationCommandOptionType.Subcommand,
name: subcommandName + 'a',
description: faker.string.alpha(),
options: [
createRandomChoice(),
{
type: ApplicationCommandOptionType.String,
name: optionName,
description: faker.string.alpha(),
autocomplete: true,
command: {
onEvent: [],
execute: vi.fn(),
},
},
},
],
},
],
},
];
let accumulator = '';
let result: unknown;
for (const char of optionName) {
accumulator += char;
],
},
],
},
];
let accumulator = '';
let result: unknown;
const table = createLookupTable(options)
for (const char of optionName) {
accumulator += char;
const focusedValue = {
name: accumulator,
value: faker.string.alpha(),
focused: true,
};
result = table.get(`<parent>/${subcommandGroupName}/${subcommandName}/${focusedValue.name}` );
}
expect(result).toBeTruthy();
});
})
const autocomplete = new AutocompleteInteraction(accumulator);
autocomplete.options.getSubcommand.mockReturnValue(subcommandName);
autocomplete.options.getFocused.mockReturnValue({
name: accumulator,
value: faker.string.alpha(),
focused: true,
});
result = treeSearch(autocomplete, options);
}
expect(result).toBeTruthy();
});
});

View File

@@ -4,48 +4,7 @@ import { CommandType } from '../../src/core/structures/enums';
import * as Id from '../../src/core/id'
import { ButtonInteraction, ModalSubmitInteraction } from 'discord.js';
vi.mock('discord.js', async (importOriginal) => {
const mod = await importOriginal()
const ModalSubmitInteraction = class {
customId;
type = 5;
isModalSubmit = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const ButtonInteraction = class {
customId;
type = 3;
componentType = 2;
isButton = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const AutocompleteInteraction = class {
type = 4;
option: string;
constructor(s: string) {
this.option = s;
}
options = {
getFocused: vi.fn(),
getSubcommand: vi.fn(),
};
};
return {
Collection: mod.Collection,
ComponentType: mod.ComponentType,
InteractionType: mod.InteractionType,
ApplicationCommandOptionType: mod.ApplicationCommandOptionType,
ApplicationCommandType: mod.ApplicationCommandType,
ModalSubmitInteraction,
ButtonInteraction,
AutocompleteInteraction,
};
});
test('id -> Text', () => {
expect(Id.create("ping", CommandType.Text)).toBe("ping_T")
})

View File

@@ -1,6 +1,7 @@
import { describe, expect, it, vi } from 'vitest';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Presence } from '../../src';
import * as Files from '../../src/core/module-loading'
import { presenceHandler } from '../../src/handlers/presence'
// Example test suite for the module function
describe('module function', () => {
@@ -54,4 +55,38 @@ describe('of function', () => {
activities: [{ name: 'Another Test Activity' }],
});
});
})
describe('Presence module execution', () => {
const mockExecuteResult = Presence.of({
status: 'online',
}).once();
const mockModule = Presence.module({
inject: [ '@sern/client'],
execute: vi.fn().mockReturnValue(mockExecuteResult)
})
beforeEach(() => {
vi.clearAllMocks();
// Mock Files.importModule
vi.spyOn(Files, 'importModule').mockResolvedValue({
module: mockModule
});
});
it('should set presence once.', async () => {
const setPresenceMock = vi.fn();
const mockPath = '/path/to/presence/config';
await presenceHandler(mockPath, setPresenceMock);
expect(Files.importModule).toHaveBeenCalledWith(mockPath);
expect(setPresenceMock).toHaveBeenCalledOnce();
})
})

View File

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

54
test/setup/setup-tests.ts Normal file
View File

@@ -0,0 +1,54 @@
import { vi } from 'vitest'
import { makeDependencies } from '../../src';
import { Client } from 'discord.js';
vi.mock('discord.js', async (importOriginal) => {
const mod = await importOriginal()
const ModalSubmitInteraction = class {
customId;
type = 5;
isModalSubmit = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const ButtonInteraction = class {
customId;
type = 3;
componentType = 2;
isButton = vi.fn();
constructor(customId) {
this.customId = customId;
}
};
const AutocompleteInteraction = class {
type = 4;
option: string;
constructor(s: string) {
this.option = s;
}
options = {
getFocused: vi.fn(),
getSubcommand: vi.fn(),
};
};
return {
Client : vi.fn(),
Collection: mod.Collection,
ComponentType: mod.ComponentType,
InteractionType: mod.InteractionType,
ApplicationCommandOptionType: mod.ApplicationCommandOptionType,
ApplicationCommandType: mod.ApplicationCommandType,
ModalSubmitInteraction,
ButtonInteraction,
AutocompleteInteraction,
ChatInputCommandInteraction: vi.fn()
};
});
await makeDependencies(({ add }) => {
add('@sern/client', { })
})

48
test/setup/util.ts Normal file
View File

@@ -0,0 +1,48 @@
import { faker } from "@faker-js/faker"
import { CommandInitPlugin, CommandType, Module, controller } from "../../src"
import { Processed } from "../../src/types/core-modules"
import { vi } from 'vitest'
export function createRandomInitPlugin (s: 'go', mut?: Partial<Module>) {
return CommandInitPlugin(({ module }) => {
if(mut) {
Object.entries(mut).forEach(([k, v]) => {
module[k] = v
})
}
return s == 'go'
? controller.next()
: controller.stop()
})
}
export function createRandomModule(plugins: any[]): Processed<Module> {
return {
type: CommandType.Both,
meta: { id:"", absPath: "" },
description: faker.string.alpha(),
plugins,
name: "cheese",
onEvent: [],
locals: {},
execute: vi.fn(),
};
}
export function createRandomChoice() {
return {
type: faker.number.int({ min: 1, max: 11 }),
name: faker.word.noun(),
description: faker.word.adjective(),
};
}
export function createRandomPlugins(len: number) {
const random = () => Math.floor(Math.random() * 2) + 1; // 1 or 2, plugin enum
return Array.from({ length: len }, () => ({
type: random(),
execute: () => (random() === 1 ? controller.next() : controller.stop()),
}));
}

8
vitest.config.ts Normal file
View File

@@ -0,0 +1,8 @@
// vitest.config.ts or vitest.config.js
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
setupFiles: ['./test/setup/setup-tests.ts'],
},
})

3293
yarn.lock

File diff suppressed because it is too large Load Diff