Compare commits

...

64 Commits

Author SHA1 Message Date
semantic-release-bot
7a379a2cae chore(release): 1.0.0-dev.27 [skip ci]
# @revanced/discord-bot [1.0.0-dev.27](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.26...@revanced/discord-bot@1.0.0-dev.27) (2024-09-05)

### Bug Fixes

* **bots/discord:** correct permission check logic ([dd8872c](dd8872c027))
* **bots/discord:** give only removed roles for role presets ([522ad28](522ad28fd8))
* **bots/discord:** replace duration parser with a library ([94c4fed](94c4fedc06))
2024-09-05 10:34:11 +00:00
Palm
dd8872c027 fix(bots/discord): correct permission check logic
Members were being previously treated as users and some requirements are passing by default when they must not.
2024-09-05 17:33:06 +07:00
semantic-release-bot
33a0e18e7b chore(release): 1.0.0-dev.27 [skip ci]
# @revanced/discord-bot [1.0.0-dev.27](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.26...@revanced/discord-bot@1.0.0-dev.27) (2024-08-23)

### Bug Fixes

* **bots/discord:** give only removed roles for role presets ([522ad28](522ad28fd8))
* **bots/discord:** replace duration parser with a library ([94c4fed](94c4fedc06))
2024-08-23 11:07:18 +00:00
PalmDevs
522ad28fd8 fix(bots/discord): give only removed roles for role presets 2024-08-23 18:05:48 +07:00
PalmDevs
94c4fedc06 fix(bots/discord): replace duration parser with a library 2024-08-23 17:59:02 +07:00
semantic-release-bot
e3dcbab508 chore(release): 1.0.0-dev.26 [skip ci]
# @revanced/discord-bot [1.0.0-dev.26](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.25...@revanced/discord-bot@1.0.0-dev.26) (2024-08-15)

### Bug Fixes

* **bots/discord:** correct timer active condition for sticky messages ([96065ff](96065ff175))
2024-08-15 15:34:29 +00:00
Palm
96065ff175 fix(bots/discord): correct timer active condition for sticky messages 2024-08-15 22:33:12 +07:00
semantic-release-bot
845dd5d914 chore(release): 1.0.0-dev.25 [skip ci]
# @revanced/discord-bot [1.0.0-dev.25](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.24...@revanced/discord-bot@1.0.0-dev.25) (2024-08-15)

### Bug Fixes

* **bots/discord:** allow access to `context` in `/eval` and await result ([99f65f0](99f65f07f5))
2024-08-15 05:11:31 +00:00
Palm
99f65f07f5 fix(bots/discord): allow access to context in /eval and await result 2024-08-15 12:10:04 +07:00
semantic-release-bot
51f877f321 chore(release): 1.0.0-dev.24 [skip ci]
# @revanced/discord-bot [1.0.0-dev.24](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.23...@revanced/discord-bot@1.0.0-dev.24) (2024-08-14)

### Bug Fixes

* **bots/discord:** do not remove unrelated reactions ([031fd26](031fd26b26))
2024-08-14 04:54:54 +00:00
PalmDevs
031fd26b26 fix(bots/discord): do not remove unrelated reactions 2024-08-14 11:53:29 +07:00
semantic-release-bot
ef07039083 chore(release): 1.0.0-dev.23 [skip ci]
# @revanced/discord-bot [1.0.0-dev.23](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.22...@revanced/discord-bot@1.0.0-dev.23) (2024-08-13)

### Features

* **bots/discord:** add `train` commands ([ee90ef2](ee90ef247b))
* **bots/discord:** update to newer command definition framework ([97f2795](97f2795df4))
2024-08-13 14:20:22 +00:00
PalmDevs
6dc7f0211e chore: update lockfile 2024-08-13 21:19:18 +07:00
PalmDevs
ee90ef247b feat(bots/discord): add train commands 2024-08-13 21:16:28 +07:00
PalmDevs
97f2795df4 feat(bots/discord): update to newer command definition framework 2024-08-13 21:16:26 +07:00
semantic-release-bot
82fac783ea chore(release): 1.0.0-dev.22 [skip ci]
# @revanced/discord-bot [1.0.0-dev.22](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.21...@revanced/discord-bot@1.0.0-dev.22) (2024-08-10)

### Bug Fixes

* **bots/discord:** parse larger units of durations, fix wrong timestamp in mod embed ([6c8dce0](6c8dce0593))
* **bots/discord:** provide discord token for `reload` command ([dd21a5a](dd21a5abad))

### Features

* **bots/discord:** add code to actually scan text files correctly ([80aeb19](80aeb19020))
2024-08-10 15:31:57 +00:00
PalmDevs
dd21a5abad fix(bots/discord): provide discord token for reload command 2024-08-10 22:30:45 +07:00
PalmDevs
80aeb19020 feat(bots/discord): add code to actually scan text files correctly 2024-08-10 22:30:45 +07:00
PalmDevs
6c8dce0593 fix(bots/discord): parse larger units of durations, fix wrong timestamp in mod embed 2024-08-10 22:30:44 +07:00
semantic-release-bot
9897f244e0 chore(release): 1.0.0-dev.21 [skip ci]
# @revanced/discord-bot [1.0.0-dev.21](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.20...@revanced/discord-bot@1.0.0-dev.21) (2024-08-04)

### Bug Fixes

* **bots/discord:** correct sticky messages logic ([de8bef6](de8bef6520))
* **bots/discord:** make `/eval` work ([eaa25f2](eaa25f2eb5))
* **bots/discord:** some configuration values not applying after running `/reload` ([a976dd2](a976dd2acc))
2024-08-04 17:45:52 +00:00
PalmDevs
eaa25f2eb5 fix(bots/discord): make /eval work 2024-08-05 00:44:35 +07:00
PalmDevs
a976dd2acc fix(bots/discord): some configuration values not applying after running /reload 2024-08-05 00:44:34 +07:00
PalmDevs
c567ef25c6 feat(packages/api): allow setting new options for ClientWebsocketManager 2024-08-05 00:44:33 +07:00
PalmDevs
de8bef6520 fix(bots/discord): correct sticky messages logic 2024-08-05 00:44:32 +07:00
semantic-release-bot
98dea81eeb chore(release): 1.0.0-dev.20 [skip ci]
# @revanced/discord-bot [1.0.0-dev.20](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.19...@revanced/discord-bot@1.0.0-dev.20) (2024-08-03)

### Bug Fixes

* **bots/discord:** await when putting entries into db ([4da6175](4da6175cf5))
2024-08-03 19:53:58 +00:00
semantic-release-bot
9f3295cc0f chore(release): 1.0.0-dev.9 [skip ci]
# @revanced/bot-websocket-api [1.0.0-dev.9](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.8...@revanced/bot-websocket-api@1.0.0-dev.9) (2024-08-03)

### Features

* **apis/websocket:** return `true` for data on a `TrainedMessage` packet ([65add4d](65add4dfee))
2024-08-03 19:53:18 +00:00
PalmDevs
4da6175cf5 fix(bots/discord): await when putting entries into db 2024-08-04 02:52:07 +07:00
PalmDevs
d90ad5c955 fix(packages/api): handle close event as a disconnect 2024-08-04 02:52:06 +07:00
PalmDevs
65add4dfee feat(apis/websocket): return true for data on a TrainedMessage packet 2024-08-04 02:52:04 +07:00
semantic-release-bot
2c2f6b76d4 chore(release): 1.0.0-dev.19 [skip ci]
# @revanced/discord-bot [1.0.0-dev.19](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.18...@revanced/discord-bot@1.0.0-dev.19) (2024-08-03)

### Bug Fixes

* **bots/discord:** correct whitelist logic ([49c29be](49c29bebfb))
2024-08-03 17:31:01 +00:00
PalmDevs
49c29bebfb fix(bots/discord): correct whitelist logic 2024-08-04 00:29:20 +07:00
semantic-release-bot
4e889d4991 chore(release): 1.0.0-dev.18 [skip ci]
# @revanced/discord-bot [1.0.0-dev.18](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.17...@revanced/discord-bot@1.0.0-dev.18) (2024-08-03)

### Bug Fixes

* **bots/discord:** set the `label` property correctly for message scans ([6d463df](6d463df586))
2024-08-03 15:24:18 +00:00
PalmDevs
6d463df586 fix(bots/discord): set the label property correctly for message scans 2024-08-03 22:22:42 +07:00
semantic-release-bot
2d8688bd4c chore(release): 1.0.0-dev.17 [skip ci]
# @revanced/discord-bot [1.0.0-dev.17](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.16...@revanced/discord-bot@1.0.0-dev.17) (2024-08-02)

### Bug Fixes

* **bots/discord/commands/eval:** evaluate in current context ([5925d90](5925d90209))
* **bots/discord:** send right response for after regexes ([a7688fa](a7688fa9b9))

### Features

* **bots/discord:** update example config file ([bc9951c](bc9951c9b5))
2024-08-02 18:40:39 +00:00
PalmDevs
bc9951c9b5 feat(bots/discord): update example config file 2024-08-03 01:36:19 +07:00
PalmDevs
a7688fa9b9 fix(bots/discord): send right response for after regexes 2024-08-03 01:31:45 +07:00
PalmDevs
5925d90209 fix(bots/discord/commands/eval): evaluate in current context 2024-08-03 01:31:44 +07:00
semantic-release-bot
5506518635 chore(release): 1.0.0-dev.16 [skip ci]
# @revanced/discord-bot [1.0.0-dev.16](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.15...@revanced/discord-bot@1.0.0-dev.16) (2024-08-02)

### Bug Fixes

* **bots/discord:** open database as read-write ([c366840](c36684091d))
* **bots/discord:** remove bad text channel checks ([f5939e2](f5939e2528))
* **bots/discord:** remove redundant footer for response embeds ([412e003](412e00317d))

### Features

* **bots/discord/commands:** add `reload` command ([6875b32](6875b32fd0))
2024-08-02 12:31:21 +00:00
PalmDevs
b9d08fff64 feat(bots/discord)!: add more attachment scan options 2024-08-02 19:26:20 +07:00
PalmDevs
6875b32fd0 feat(bots/discord/commands): add reload command 2024-08-02 19:26:20 +07:00
PalmDevs
c36684091d fix(bots/discord): open database as read-write 2024-08-02 19:26:19 +07:00
PalmDevs
f5939e2528 fix(bots/discord): remove bad text channel checks 2024-08-02 19:26:18 +07:00
PalmDevs
412e00317d fix(bots/discord): remove redundant footer for response embeds 2024-08-02 19:26:17 +07:00
PalmDevs
8fe78e424e fix(bots/discord)!: rename config replyToReplied to respondToReply 2024-08-02 19:26:12 +07:00
semantic-release-bot
9fe6b4ca70 chore(release): 1.0.0-dev.15 [skip ci]
# @revanced/discord-bot [1.0.0-dev.15](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.14...@revanced/discord-bot@1.0.0-dev.15) (2024-07-31)

### Bug Fixes

* **bots/discord:** import `config` from context ([763ef25](763ef253f9))

### Features

* **bots/discord:** add sticky messages ([bf66155](bf661556e1))
2024-07-31 19:31:21 +00:00
PalmDevs
bf661556e1 feat(bots/discord): add sticky messages 2024-08-01 02:29:49 +07:00
PalmDevs
763ef253f9 fix(bots/discord): import config from context 2024-07-31 21:25:12 +07:00
semantic-release-bot
561426028c chore(release): 1.0.0-dev.14 [skip ci]
# @revanced/discord-bot [1.0.0-dev.14](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.13...@revanced/discord-bot@1.0.0-dev.14) (2024-07-31)

### Bug Fixes

* **bots/discord:** always true check causing no messages to be scanned ([98ec37b](98ec37b5d1))
* other small issues ([bc437a5](bc437a5ec7))

### Features

* **bots/discord:** add more options for curing, fix default regex ([1a4ec1e](1a4ec1ece8))
* **bots/discord:** allow admins to bypass permission checks ([620f933](620f9339f0))
2024-07-31 12:36:12 +00:00
semantic-release-bot
b726c40fd4 chore(release): 1.0.0-dev.8 [skip ci]
# @revanced/bot-websocket-api [1.0.0-dev.8](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.7...@revanced/bot-websocket-api@1.0.0-dev.8) (2024-07-31)

### Bug Fixes

* other small issues ([bc437a5](bc437a5ec7))
2024-07-31 12:35:31 +00:00
PalmDevs
bc437a5ec7 fix: other small issues 2024-07-31 19:34:12 +07:00
PalmDevs
620f9339f0 feat(bots/discord): allow admins to bypass permission checks 2024-07-31 19:34:11 +07:00
PalmDevs
1a4ec1ece8 feat(bots/discord): add more options for curing, fix default regex 2024-07-31 19:34:10 +07:00
PalmDevs
98ec37b5d1 fix(bots/discord): always true check causing no messages to be scanned 2024-07-31 19:34:10 +07:00
PalmDevs
711f57f4a1 fix(packages/api): null errors being thrown 2024-07-31 19:34:09 +07:00
PalmDevs
e29e9c3dd1 fix(packages/api): properly await failed packets 2024-07-31 19:34:08 +07:00
semantic-release-bot
b832311f7e chore(release): 1.0.0-dev.13 [skip ci]
# @revanced/discord-bot [1.0.0-dev.13](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.12...@revanced/discord-bot@1.0.0-dev.13) (2024-07-30)

### Bug Fixes

* **bots/discord:** broken regex when prefix set to special characters ([ab62e55](ab62e55e76))
2024-07-30 19:14:27 +00:00
semantic-release-bot
887ee85e41 chore(release): 1.0.0-dev.7 [skip ci]
# @revanced/bot-websocket-api [1.0.0-dev.7](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.6...@revanced/bot-websocket-api@1.0.0-dev.7) (2024-07-30)
2024-07-30 19:13:45 +00:00
PalmDevs
c9b788dc51 build(Needs bump): do not minify builds 2024-07-31 02:12:31 +07:00
PalmDevs
ab62e55e76 fix(bots/discord): broken regex when prefix set to special characters 2024-07-31 02:12:30 +07:00
semantic-release-bot
8f83687b7c chore(release): 1.0.0-dev.12 [skip ci]
# @revanced/discord-bot [1.0.0-dev.12](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.11...@revanced/discord-bot@1.0.0-dev.12) (2024-07-30)

### Bug Fixes

* **bots/discord:** deployment runtime errors due to minification ([a60c60c](a60c60c0f9))
2024-07-30 18:42:20 +00:00
PalmDevs
a60c60c0f9 fix(bots/discord): deployment runtime errors due to minification 2024-07-31 01:40:45 +07:00
semantic-release-bot
95a122a225 chore(release): 1.0.0-dev.11 [skip ci]
# @revanced/discord-bot [1.0.0-dev.11](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.10...@revanced/discord-bot@1.0.0-dev.11) (2024-07-30)

### Bug Fixes

* **bots/discord:** reset counter when reconnected to api, redo message scan filter logic ([d234d79](d234d79310))
2024-07-30 17:23:25 +00:00
PalmDevs
d234d79310 fix(bots/discord): reset counter when reconnected to api, redo message scan filter logic 2024-07-31 00:22:02 +07:00
semantic-release-bot
3188f8dbed chore(release): 1.0.0-dev.10 [skip ci]
# @revanced/discord-bot [1.0.0-dev.10](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.9...@revanced/discord-bot@1.0.0-dev.10) (2024-07-30)

### Bug Fixes

* **bots/discord:** hanging process when disconnecting from API too many times ([d31616e](d31616ebcb))
2024-07-30 14:32:44 +00:00
52 changed files with 1180 additions and 346 deletions

View File

@@ -1,3 +1,19 @@
# @revanced/bot-websocket-api [1.0.0-dev.9](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.8...@revanced/bot-websocket-api@1.0.0-dev.9) (2024-08-03)
### Features
* **apis/websocket:** return `true` for data on a `TrainedMessage` packet ([65add4d](https://github.com/revanced/revanced-helper/commit/65add4dfeed2fa067c2c8e2377f7d01d505ade54))
# @revanced/bot-websocket-api [1.0.0-dev.8](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.7...@revanced/bot-websocket-api@1.0.0-dev.8) (2024-07-31)
### Bug Fixes
* other small issues ([bc437a5](https://github.com/revanced/revanced-helper/commit/bc437a5ec7ce1d339094d608e2a61ac5f460c163))
# @revanced/bot-websocket-api [1.0.0-dev.7](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.6...@revanced/bot-websocket-api@1.0.0-dev.7) (2024-07-30)
# @revanced/bot-websocket-api [1.0.0-dev.6](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.5...@revanced/bot-websocket-api@1.0.0-dev.6) (2024-07-30) # @revanced/bot-websocket-api [1.0.0-dev.6](https://github.com/revanced/revanced-helper/compare/@revanced/bot-websocket-api@1.0.0-dev.5...@revanced/bot-websocket-api@1.0.0-dev.6) (2024-07-30)

View File

@@ -2,7 +2,7 @@
"name": "@revanced/bot-websocket-api", "name": "@revanced/bot-websocket-api",
"type": "module", "type": "module",
"private": true, "private": true,
"version": "1.0.0-dev.6", "version": "1.0.0-dev.9",
"description": "🧦 WebSocket API server for bots assisting ReVanced", "description": "🧦 WebSocket API server for bots assisting ReVanced",
"main": "dist/index.js", "main": "dist/index.js",
"scripts": { "scripts": {

View File

@@ -11,7 +11,6 @@ await Bun.build({
entrypoints: ['./src/index.ts'], entrypoints: ['./src/index.ts'],
outdir: './dist', outdir: './dist',
target: 'bun', target: 'bun',
minify: true,
sourcemap: 'external', sourcemap: 'external',
}) })
@@ -21,7 +20,6 @@ await Bun.build({
external: ['tesseract.js-core/*'], external: ['tesseract.js-core/*'],
target: 'bun', target: 'bun',
outdir: './dist/worker', outdir: './dist/worker',
minify: true,
sourcemap: 'external', sourcemap: 'external',
}) })

View File

@@ -18,7 +18,7 @@ const trainMessageEventHandler: EventHandler<ClientOperation.TrainMessage> = asy
client.send( client.send(
{ {
op: ServerOperation.TrainedMessage, op: ServerOperation.TrainedMessage,
d: null, d: true,
}, },
nextSeq, nextSeq,
) )

View File

@@ -174,9 +174,6 @@ dist
# Finder (MacOS) folder config # Finder (MacOS) folder config
.DS_Store .DS_Store
# Config
config.ts
# DB # DB
*.db *.db
*.sqlite *.sqlite

View File

@@ -1,3 +1,173 @@
# @revanced/discord-bot [1.0.0-dev.27](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.26...@revanced/discord-bot@1.0.0-dev.27) (2024-09-05)
### Bug Fixes
* **bots/discord:** correct permission check logic ([dd8872c](https://github.com/revanced/revanced-helper/commit/dd8872c027c7e7e1a00f38d659b4d6e79274238c))
* **bots/discord:** give only removed roles for role presets ([522ad28](https://github.com/revanced/revanced-helper/commit/522ad28fd83565e9ca411dbce86c8447574288fd))
* **bots/discord:** replace duration parser with a library ([94c4fed](https://github.com/revanced/revanced-helper/commit/94c4fedc06e20051e4123508e3134b97eb84782a))
# @revanced/discord-bot [1.0.0-dev.27](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.26...@revanced/discord-bot@1.0.0-dev.27) (2024-08-23)
### Bug Fixes
* **bots/discord:** give only removed roles for role presets ([522ad28](https://github.com/revanced/revanced-helper/commit/522ad28fd83565e9ca411dbce86c8447574288fd))
* **bots/discord:** replace duration parser with a library ([94c4fed](https://github.com/revanced/revanced-helper/commit/94c4fedc06e20051e4123508e3134b97eb84782a))
# @revanced/discord-bot [1.0.0-dev.26](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.25...@revanced/discord-bot@1.0.0-dev.26) (2024-08-15)
### Bug Fixes
* **bots/discord:** correct timer active condition for sticky messages ([96065ff](https://github.com/revanced/revanced-helper/commit/96065ff17584ff99a56ca5008327863ca5a7852b))
# @revanced/discord-bot [1.0.0-dev.25](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.24...@revanced/discord-bot@1.0.0-dev.25) (2024-08-15)
### Bug Fixes
* **bots/discord:** allow access to `context` in `/eval` and await result ([99f65f0](https://github.com/revanced/revanced-helper/commit/99f65f07f5f8830c6e8ea4ae171e986af4d3f1f6))
# @revanced/discord-bot [1.0.0-dev.24](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.23...@revanced/discord-bot@1.0.0-dev.24) (2024-08-14)
### Bug Fixes
* **bots/discord:** do not remove unrelated reactions ([031fd26](https://github.com/revanced/revanced-helper/commit/031fd26b2619ecafeff3964e50accacb87de6108))
# @revanced/discord-bot [1.0.0-dev.23](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.22...@revanced/discord-bot@1.0.0-dev.23) (2024-08-13)
### Features
* **bots/discord:** add `train` commands ([ee90ef2](https://github.com/revanced/revanced-helper/commit/ee90ef247b4bf2b3c0698606b947116f2dc1b868))
* **bots/discord:** update to newer command definition framework ([97f2795](https://github.com/revanced/revanced-helper/commit/97f2795df4ede4d12a08193dba453c1bc765a4c2))
# @revanced/discord-bot [1.0.0-dev.22](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.21...@revanced/discord-bot@1.0.0-dev.22) (2024-08-10)
### Bug Fixes
* **bots/discord:** parse larger units of durations, fix wrong timestamp in mod embed ([6c8dce0](https://github.com/revanced/revanced-helper/commit/6c8dce059366a6ef85f5b8b1794c056515b9f5b6))
* **bots/discord:** provide discord token for `reload` command ([dd21a5a](https://github.com/revanced/revanced-helper/commit/dd21a5abad560f3d00b8c58912786d4b6bd520e9))
### Features
* **bots/discord:** add code to actually scan text files correctly ([80aeb19](https://github.com/revanced/revanced-helper/commit/80aeb1902063140a2e78cfaed9424e5101ab03f1))
# @revanced/discord-bot [1.0.0-dev.21](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.20...@revanced/discord-bot@1.0.0-dev.21) (2024-08-04)
### Bug Fixes
* **bots/discord:** correct sticky messages logic ([de8bef6](https://github.com/revanced/revanced-helper/commit/de8bef6520d53a1299f0478458320a7eb75c5e1d))
* **bots/discord:** make `/eval` work ([eaa25f2](https://github.com/revanced/revanced-helper/commit/eaa25f2eb58a9e2d25bb98633ad668485e099714))
* **bots/discord:** some configuration values not applying after running `/reload` ([a976dd2](https://github.com/revanced/revanced-helper/commit/a976dd2accc4b74914651245acde0979c30c92f5))
# @revanced/discord-bot [1.0.0-dev.20](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.19...@revanced/discord-bot@1.0.0-dev.20) (2024-08-03)
### Bug Fixes
* **bots/discord:** await when putting entries into db ([4da6175](https://github.com/revanced/revanced-helper/commit/4da6175cf58b1fa6144bdc71ec806766d32c1025))
# @revanced/discord-bot [1.0.0-dev.19](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.18...@revanced/discord-bot@1.0.0-dev.19) (2024-08-03)
### Bug Fixes
* **bots/discord:** correct whitelist logic ([49c29be](https://github.com/revanced/revanced-helper/commit/49c29bebfbe348ae4e2cc1b3a83bfa41eb26ccd1))
# @revanced/discord-bot [1.0.0-dev.18](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.17...@revanced/discord-bot@1.0.0-dev.18) (2024-08-03)
### Bug Fixes
* **bots/discord:** set the `label` property correctly for message scans ([6d463df](https://github.com/revanced/revanced-helper/commit/6d463df586dee5dd8fe8d6cff1c5316f7809b32a))
# @revanced/discord-bot [1.0.0-dev.17](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.16...@revanced/discord-bot@1.0.0-dev.17) (2024-08-02)
### Bug Fixes
* **bots/discord/commands/eval:** evaluate in current context ([5925d90](https://github.com/revanced/revanced-helper/commit/5925d902095acef5f6396ca03583a9cbb0862498))
* **bots/discord:** send right response for after regexes ([a7688fa](https://github.com/revanced/revanced-helper/commit/a7688fa9b91919a87f74071b502cd0a87cd1c1fa))
### Features
* **bots/discord:** update example config file ([bc9951c](https://github.com/revanced/revanced-helper/commit/bc9951c9b5e007c3e1b3076aa0966ccf29bb18bc))
# @revanced/discord-bot [1.0.0-dev.16](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.15...@revanced/discord-bot@1.0.0-dev.16) (2024-08-02)
### Bug Fixes
* **bots/discord:** open database as read-write ([c366840](https://github.com/revanced/revanced-helper/commit/c36684091dddf67880505dc459e4334a8a5492f4))
* **bots/discord:** remove bad text channel checks ([f5939e2](https://github.com/revanced/revanced-helper/commit/f5939e25288fea2022fdeec9085ecb9ffada6111))
* **bots/discord:** remove redundant footer for response embeds ([412e003](https://github.com/revanced/revanced-helper/commit/412e00317d1eaca23e9c1375e16f94a5f2fa8d86))
### Features
* **bots/discord/commands:** add `reload` command ([6875b32](https://github.com/revanced/revanced-helper/commit/6875b32fd0c6ce3034da9dc6c704d425afb26f2e))
# @revanced/discord-bot [1.0.0-dev.15](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.14...@revanced/discord-bot@1.0.0-dev.15) (2024-07-31)
### Bug Fixes
* **bots/discord:** import `config` from context ([763ef25](https://github.com/revanced/revanced-helper/commit/763ef253f9d4ff70a8b79969a7f4f41cba7f3c59))
### Features
* **bots/discord:** add sticky messages ([bf66155](https://github.com/revanced/revanced-helper/commit/bf661556e131bf0ef24e47f658fbcd701960e312))
# @revanced/discord-bot [1.0.0-dev.14](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.13...@revanced/discord-bot@1.0.0-dev.14) (2024-07-31)
### Bug Fixes
* **bots/discord:** always true check causing no messages to be scanned ([98ec37b](https://github.com/revanced/revanced-helper/commit/98ec37b5d18cade85270ab83b0ed0abe41244dd9))
* other small issues ([bc437a5](https://github.com/revanced/revanced-helper/commit/bc437a5ec7ce1d339094d608e2a61ac5f460c163))
### Features
* **bots/discord:** add more options for curing, fix default regex ([1a4ec1e](https://github.com/revanced/revanced-helper/commit/1a4ec1ece80becd9156582cc490f6681cb2a1f39))
* **bots/discord:** allow admins to bypass permission checks ([620f933](https://github.com/revanced/revanced-helper/commit/620f9339f0737b79d72c66d90ffa42ea3f987710))
# @revanced/discord-bot [1.0.0-dev.13](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.12...@revanced/discord-bot@1.0.0-dev.13) (2024-07-30)
### Bug Fixes
* **bots/discord:** broken regex when prefix set to special characters ([ab62e55](https://github.com/revanced/revanced-helper/commit/ab62e55e76005f5999d7413d1158e54053f28d1f))
# @revanced/discord-bot [1.0.0-dev.12](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.11...@revanced/discord-bot@1.0.0-dev.12) (2024-07-30)
### Bug Fixes
* **bots/discord:** deployment runtime errors due to minification ([a60c60c](https://github.com/revanced/revanced-helper/commit/a60c60c0f994a4c256b7d0582e99a1731209cf49))
# @revanced/discord-bot [1.0.0-dev.11](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.10...@revanced/discord-bot@1.0.0-dev.11) (2024-07-30)
### Bug Fixes
* **bots/discord:** reset counter when reconnected to api, redo message scan filter logic ([d234d79](https://github.com/revanced/revanced-helper/commit/d234d79310caed9c43e14a905f9ef46a110e071d))
# @revanced/discord-bot [1.0.0-dev.10](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.9...@revanced/discord-bot@1.0.0-dev.10) (2024-07-30)
### Bug Fixes
* **bots/discord:** hanging process when disconnecting from API too many times ([d31616e](https://github.com/revanced/revanced-helper/commit/d31616ebcba6f1dcd8bde183bcb8d1adb1501b61))
# @revanced/discord-bot [1.0.0-dev.9](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.8...@revanced/discord-bot@1.0.0-dev.9) (2024-07-30) # @revanced/discord-bot [1.0.0-dev.9](https://github.com/revanced/revanced-helper/compare/@revanced/discord-bot@1.0.0-dev.8...@revanced/discord-bot@1.0.0-dev.9) (2024-07-30)

View File

@@ -11,8 +11,21 @@ export default {
GUILD_ID_HERE: ['ROLE_ID_HERE'], GUILD_ID_HERE: ['ROLE_ID_HERE'],
}, },
}, },
stickyMessages: {
GUILD_ID_HERE: {
CHANNEL_ID_HERE: {
message: {
content: 'This is a sticky message!',
},
timeout: 60000,
forceSendTimeout: 300000,
}
}
},
moderation: { moderation: {
cure: { cure: {
minimumNameLength: 3,
removeCharactersRegex: /[^a-zA-Z0-9 \-_]/g,
defaultName: 'Server member', defaultName: 'Server member',
}, },
roles: ['ROLE_ID_HERE'], roles: ['ROLE_ID_HERE'],
@@ -61,7 +74,11 @@ export default {
}, },
}, },
}, },
allowedAttachmentMimeTypes: ['image/jpeg', 'image/png', 'image/webp'], attachments: {
scanAttachments: true,
allowedMimeTypes: ['image/jpeg', 'image/png', 'image/webp', 'text/plain'],
maxTextFileSize: 512000
},
responses: [ responses: [
{ {
filterOverride: { filterOverride: {

View File

@@ -6,9 +6,12 @@ export type Config = {
users?: string[] users?: string[]
roles?: Record<string, string[]> roles?: Record<string, string[]>
} }
stickyMessages?: Record<string, Record<string, StickyMessageConfig>>
moderation?: { moderation?: {
roles: string[] roles: string[]
cure?: { cure?: {
minimumNameLength?: number
removeCharactersRegex?: RegExp
defaultName: string defaultName: string
} }
log?: { log?: {
@@ -23,7 +26,11 @@ export type Config = {
messageScan?: { messageScan?: {
scanBots?: boolean scanBots?: boolean
scanOutsideGuilds?: boolean scanOutsideGuilds?: boolean
allowedAttachmentMimeTypes: string[] attachments?: {
scanAttachments?: boolean
allowedMimeTypes?: string[]
maxTextFileSize?: number
}
filter?: { filter?: {
whitelist?: Filter whitelist?: Filter
blacklist?: Filter blacklist?: Filter
@@ -48,6 +55,12 @@ export type Config = {
} }
} }
export type StickyMessageConfig = {
timeout: number
forceSendTimeout?: number
message: BaseMessageOptions
}
export type RolePresetConfig = { export type RolePresetConfig = {
give: string[] give: string[]
take: string[] take: string[]
@@ -60,7 +73,7 @@ export type ConfigMessageScanResponse = {
} }
filterOverride?: NonNullable<Config['messageScan']>['filter'] filterOverride?: NonNullable<Config['messageScan']>['filter']
response: ConfigMessageScanResponseMessage | null response: ConfigMessageScanResponseMessage | null
replyToReplied?: boolean respondToReply?: boolean
} }
export type ConfigMessageScanResponseLabelConfig = { export type ConfigMessageScanResponseLabelConfig = {

View File

@@ -2,16 +2,15 @@
"name": "@revanced/discord-bot", "name": "@revanced/discord-bot",
"type": "module", "type": "module",
"private": true, "private": true,
"version": "1.0.0-dev.9", "version": "1.0.0-dev.27",
"description": "🤖 Discord bot assisting ReVanced", "description": "🤖 Discord bot assisting ReVanced",
"main": "src/index.ts", "main": "src/index.ts",
"scripts": { "scripts": {
"register": "bun run scripts/reload-slash-commands.ts",
"start": "bun prepare && bun run src/index.ts", "start": "bun prepare && bun run src/index.ts",
"dev": "bun prepare && bun --watch src/index.ts", "dev": "bun prepare && bun --watch src/index.ts",
"build": "bun prepare && bun run scripts/build.ts", "build": "bun prepare && bun run scripts/build.ts",
"watch": "bun dev", "watch": "bun dev",
"prepare": "bun run scripts/generate-indexes.ts && bunx drizzle-kit generate --name=schema" "prepare": "bun run scripts/generate-indexes.ts && bunx --bun drizzle-kit generate --name=schema"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
@@ -34,13 +33,14 @@
"@revanced/bot-api": "workspace:*", "@revanced/bot-api": "workspace:*",
"@revanced/bot-shared": "workspace:*", "@revanced/bot-shared": "workspace:*",
"chalk": "^5.3.0", "chalk": "^5.3.0",
"decancer": "^3.2.3", "decancer": "^3.2.4",
"discord.js": "^14.15.3", "discord.js": "^14.15.3",
"drizzle-orm": "^0.31.4" "drizzle-orm": "^0.31.4",
"parse-duration": "^1.1.0"
}, },
"devDependencies": { "devDependencies": {
"@libsql/client": "^0.7.0", "@libsql/client": "^0.7.0",
"discord-api-types": "^0.37.92", "discord-api-types": "^0.37.97",
"drizzle-kit": "^0.22.8" "drizzle-kit": "^0.22.8"
} }
} }

View File

@@ -12,7 +12,6 @@ await Bun.build({
outdir: './dist/src', outdir: './dist/src',
target: 'bun', target: 'bun',
external: ['./config.js'], external: ['./config.js'],
minify: true,
sourcemap: 'external', sourcemap: 'external',
}) })

View File

@@ -1,76 +1,145 @@
import { ApplicationCommandOptionType } from 'discord.js' import { ApplicationCommandOptionType, ApplicationCommandType } from 'discord.js'
import { isAdmin } from '../utils/discord/permissions'
import { createErrorEmbed } from '$/utils/discord/embeds'
import { isAdmin } from '$/utils/discord/permissions'
import { config } from '../context'
import CommandError, { CommandErrorType } from './CommandError' import CommandError, { CommandErrorType } from './CommandError'
import type { Filter } from 'config.schema'
import type { import type {
APIApplicationCommandChannelOption, APIApplicationCommandChannelOption,
CacheType, CacheType,
Channel, Channel,
ChatInputCommandInteraction, ChatInputCommandInteraction,
CommandInteraction,
CommandInteractionOption, CommandInteractionOption,
GuildMember, GuildMember,
Message, Message,
MessageContextMenuCommandInteraction,
RESTPostAPIApplicationCommandsJSONBody,
RESTPostAPIChatInputApplicationCommandsJSONBody, RESTPostAPIChatInputApplicationCommandsJSONBody,
Role, Role,
User, User,
UserContextMenuCommandInteraction,
UserResolvable,
} from 'discord.js' } from 'discord.js'
import { config } from '../context'
export enum CommandType {
ChatGlobal = 1,
ChatGuild,
ContextMenuUser,
ContextMenuMessage,
ContextMenuGuildMessage,
ContextMenuGuildMember,
}
export default class Command< export default class Command<
Global extends boolean = false, const Type extends CommandType = CommandType.ChatGuild,
Options extends CommandOptionsOptions | undefined = undefined, const Options extends If<IsContextMenu<Type>, undefined, CommandOptionsOptions | undefined> = undefined,
AllowMessageCommand extends boolean = false, const AllowMessageCommand extends If<IsContextMenu<Type>, false, boolean> = false,
> { > {
name: string name: string
description: string description: string
requirements?: CommandRequirements requirements?: CommandRequirements
options?: Options options?: Options
global?: Global type: Type
#execute: CommandExecuteFunction<Global, Options, AllowMessageCommand> allowMessageCommand: AllowMessageCommand
#execute: CommandExecuteFunction<Type, Options, AllowMessageCommand>
static OptionType = ApplicationCommandOptionType static OptionType = ApplicationCommandOptionType
static Type = CommandType
constructor({ constructor({
name, name,
description, description,
requirements, requirements,
options, options,
global, type,
allowMessageCommand,
execute, execute,
}: CommandOptions<Global, Options, AllowMessageCommand>) { }: CommandOptions<Type, Options, AllowMessageCommand>) {
this.name = name this.name = name
this.description = description this.description = description!
this.requirements = requirements this.requirements = requirements
this.options = options this.options = options
this.global = global // @ts-expect-error: Default is `CommandType.GuildOnly`, it makes sense
this.type = type ?? CommandType.ChatGuild
// @ts-expect-error: Default is `false`, it makes sense
this.allowMessageCommand = allowMessageCommand ?? false
this.#execute = execute this.#execute = execute
} }
isGuildSpecific(): this is Command<
CommandType.ChatGuild | CommandType.ContextMenuGuildMember | CommandType.ContextMenuGuildMessage,
Options,
AllowMessageCommand
> {
return [
CommandType.ChatGuild,
CommandType.ContextMenuGuildMessage,
CommandType.ContextMenuGuildMember,
].includes(this.type)
}
isContextMenuSpecific(): this is Command<
| CommandType.ContextMenuGuildMessage
| CommandType.ContextMenuGuildMember
| CommandType.ContextMenuUser
| CommandType.ContextMenuMessage,
undefined,
false
> {
return [
CommandType.ContextMenuMessage,
CommandType.ContextMenuUser,
CommandType.ContextMenuGuildMessage,
CommandType.ContextMenuGuildMember,
].includes(this.type)
}
isGuildContextMenuSpecific(): this is Command<
CommandType.ContextMenuGuildMessage | CommandType.ContextMenuGuildMember,
undefined,
false
> {
return [CommandType.ContextMenuGuildMessage, CommandType.ContextMenuGuildMember].includes(this.type)
}
async onContextMenuInteraction(
context: typeof import('../context'),
interaction: If<
Extends<Type, CommandType.ContextMenuGuildMessage>,
MessageContextMenuCommandInteraction<ToCacheType<Type>>,
UserContextMenuCommandInteraction<ToCacheType<Type>>
>,
): Promise<unknown> {
if (!this.isGuildSpecific() && !interaction.inGuild())
throw new CommandError(CommandErrorType.InteractionNotInGuild)
const executor = await this.#fetchInteractionExecutor(interaction)
const target =
this.type === CommandType.ContextMenuGuildMember
? this.isGuildSpecific()
? fetchMember(interaction as CommandInteraction<'raw' | 'cached'>, interaction.targetId)
: interaction.client.users.fetch(interaction.targetId)
: interaction.channel?.messages.fetch(interaction.targetId)
if (!target) throw new CommandError(CommandErrorType.FetchManagerNotFound)
// @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough
return await this.#execute({ ...context, executor, target }, interaction, undefined)
}
async onMessage( async onMessage(
context: typeof import('../context'), context: typeof import('../context'),
msg: Message<If<Global, false, true>>, msg: Message<IsGuildSpecific<Type>>,
args: CommandArguments, args: CommandArguments,
): Promise<unknown> { ): Promise<unknown> {
if (!this.global && !msg.inGuild()) if (!this.allowMessageCommand) return
return await msg.reply({ if (!this.isGuildSpecific() && !msg.guildId) throw new CommandError(CommandErrorType.InteractionNotInGuild)
embeds: [createErrorEmbed('Cannot run this command', 'This command can only be used in a server.')],
})
const executor = this.global ? msg.author : await msg.guild?.members.fetch(msg.author.id)! const executor = this.isGuildSpecific()
? await msg.guild?.members.fetch(msg.author)!
if (!(await this.canExecute(executor, msg.channelId))) : await msg.client.users.fetch(msg.author)
return await msg.reply({ if (!(await this.canExecute(executor))) throw new CommandError(CommandErrorType.RequirementsNotMet)
embeds: [
createErrorEmbed(
'Cannot run this command',
'You do not meet the requirements to run this command.',
),
],
})
const options = this.options const options = this.options
? ((await this.#resolveMessageOptions(msg, this.options, args)) as CommandExecuteFunctionOptionsParameter< ? ((await this.#resolveMessageOptions(msg, this.options, args)) as CommandExecuteFunctionOptionsParameter<
@@ -120,14 +189,18 @@ export default class Command<
`Invalid type for argument **${name}**.${argExplainationString}\n\nExpected type: **${expectedType}**\nGot type: **${ApplicationCommandOptionType[arg.type]}**${choicesString}`, `Invalid type for argument **${name}**.${argExplainationString}\n\nExpected type: **${expectedType}**\nGot type: **${ApplicationCommandOptionType[arg.type]}**${choicesString}`,
) )
if ('choices' in option && option.choices && !option.choices.some(({ value }) => value === arg)) const argValue = typeof arg === 'string' ? arg : arg?.id
if (
'choices' in option &&
option.choices &&
!option.choices.some(({ value }) => value === (typeof value === 'number' ? Number(argValue) : argValue))
)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidArgument, CommandErrorType.InvalidArgument,
`Invalid choice for argument **${name}**.\n${argExplainationString}\n\n${choicesString}\n`, `Invalid choice for argument **${name}**.\n${argExplainationString}${choicesString}\n`,
) )
const argValue = typeof arg === 'string' ? arg : arg?.id
if (argValue && arg) { if (argValue && arg) {
if (isSubcommandLikeOption) { if (isSubcommandLikeOption) {
const [subcommandName, subcommandOption] = iterableOptions.find(([name]) => name === argValue)! const [subcommandName, subcommandOption] = iterableOptions.find(([name]) => name === argValue)!
@@ -142,6 +215,16 @@ export default class Command<
break break
} }
if (
type === ApplicationCommandOptionType.String &&
((typeof option.minLength === 'number' && argValue.length < option.minLength) ||
(typeof option.maxLength === 'number' && argValue.length > option.maxLength))
)
throw new CommandError(
CommandErrorType.InvalidArgument,
`Invalid string length for argument **${name}**.\nLengths allowed: ${option.minLength ?? '(any)'} - ${option.maxLength ?? '(any)'}.${argExplainationString}`,
)
if ( if (
(type === ApplicationCommandOptionType.Channel || (type === ApplicationCommandOptionType.Channel ||
type === ApplicationCommandOptionType.User || type === ApplicationCommandOptionType.User ||
@@ -153,14 +236,21 @@ export default class Command<
`Malformed ID for argument **${name}**.${argExplainationString}`, `Malformed ID for argument **${name}**.${argExplainationString}`,
) )
if ( if (type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer) {
(type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer) && if (Number.isNaN(Number(argValue)))
Number.isNaN(Number(argValue)) throw new CommandError(
) { CommandErrorType.InvalidArgument,
throw new CommandError( `Invalid number for argument **${name}**.${argExplainationString}`,
CommandErrorType.InvalidArgument, )
`Invalid number for argument **${name}**.${argExplainationString}`,
if (
(typeof option.min === 'number' && Number(argValue) < option.min) ||
(typeof option.max === 'number' && Number(argValue) > option.max)
) )
throw new CommandError(
CommandErrorType.InvalidArgument,
`Number out of range for argument **${name}**.\nRange allowed: ${option.min ?? '(any)'} - ${option.max ?? '(any)'}.${argExplainationString}`,
)
} }
if ( if (
@@ -177,7 +267,7 @@ export default class Command<
type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer type === ApplicationCommandOptionType.Number || type === ApplicationCommandOptionType.Integer
? Number(argValue) ? Number(argValue)
: type === ApplicationCommandOptionType.Boolean : type === ApplicationCommandOptionType.Boolean
? argValue[0] === 't' || argValue[0] === 'y' ? ['t', 'y', 'yes', 'true'].some(value => value === argValue.toLowerCase())
: type === ApplicationCommandOptionType.Channel : type === ApplicationCommandOptionType.Channel
? await msg.client.channels.fetch(argValue) ? await msg.client.channels.fetch(argValue)
: type === ApplicationCommandOptionType.User : type === ApplicationCommandOptionType.User
@@ -191,44 +281,27 @@ export default class Command<
return _options return _options
} }
#fetchInteractionExecutor(interaction: CommandInteraction) {
return this.isGuildSpecific()
? fetchMember(interaction as CommandInteraction<'raw' | 'cached'>)
: fetchUser(interaction)
}
async onInteraction( async onInteraction(
context: typeof import('../context'), context: typeof import('../context'),
interaction: ChatInputCommandInteraction, interaction: ChatInputCommandInteraction,
): Promise<unknown> { ): Promise<unknown> {
const { logger } = context if (interaction.commandName !== this.name)
throw new CommandError(
CommandErrorType.InteractionDataMismatch,
'The interaction command name does not match the expected command name.',
)
if (interaction.commandName !== this.name) { if (!this.isGuildSpecific() && !interaction.inGuild())
logger.warn(`Command name mismatch, expected ${this.name}, but got ${interaction.commandName}!`) throw new CommandError(CommandErrorType.InteractionNotInGuild)
return await interaction.reply({
embeds: [
createErrorEmbed(
'Internal command name mismatch',
'The interaction command name does not match the expected command name.',
),
],
})
}
if (!this.global && !interaction.inGuild()) { const executor = await this.#fetchInteractionExecutor(interaction)
logger.error(`Command ${this.name} cannot be run in DMs, but was registered as global`) if (!(await this.canExecute(executor))) throw new CommandError(CommandErrorType.RequirementsNotMet)
return await interaction.reply({
embeds: [createErrorEmbed('Cannot run this command', 'This command can only be used in a server.')],
ephemeral: true,
})
}
const executor = this.global ? interaction.user : await interaction.guild?.members.fetch(interaction.user.id)!
if (!(await this.canExecute(executor, interaction.channelId)))
return await interaction.reply({
embeds: [
createErrorEmbed(
'Cannot run this command',
'You do not meet the requirements to run this command.',
),
],
ephemeral: true,
})
const options = this.options const options = this.options
? ((await this.#resolveInteractionOptions(interaction)) as CommandExecuteFunctionOptionsParameter< ? ((await this.#resolveInteractionOptions(interaction)) as CommandExecuteFunctionOptionsParameter<
@@ -237,14 +310,10 @@ export default class Command<
: undefined : undefined
if (options === null) if (options === null)
return await interaction.reply({ throw new CommandError(
embeds: [ CommandErrorType.InteractionDataMismatch,
createErrorEmbed( 'The registered interaction command option type does not match the expected command option type.',
'Internal command option type mismatch', )
'The interaction command option type does not match the expected command option type.',
),
],
})
// @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough // @ts-expect-error: Type mismatch (again!) because TypeScript is not smart enough
return await this.#execute({ ...context, executor }, interaction, options) return await this.#execute({ ...context, executor }, interaction, options)
@@ -288,12 +357,14 @@ export default class Command<
return _options return _options
} }
async canExecute(executor: User | GuildMember, channelId: string): Promise<boolean> { async canExecute(executor: User | GuildMember): Promise<boolean> {
if (!this.requirements) return false if (!this.requirements) return false
const isExecutorAdmin = isAdmin(executor)
if (isExecutorAdmin) return true
const { const {
adminOnly, adminOnly,
channels,
roles, roles,
permissions, permissions,
users, users,
@@ -302,16 +373,23 @@ export default class Command<
memberRequirementsForUsers = 'pass', memberRequirementsForUsers = 'pass',
} = this.requirements } = this.requirements
const member = this.global ? null : (executor as GuildMember) const member = this.isGuildSpecific() ? (executor as GuildMember) : null
const bDefCond = defaultCondition !== 'fail' const boolDefaultCondition = defaultCondition !== 'fail'
const bMemReqForUsers = memberRequirementsForUsers !== 'fail' const boolMemberRequirementsForUsers = memberRequirementsForUsers !== 'fail'
const conditions = [ const conditions = [
adminOnly ? isAdmin(executor) : bDefCond, adminOnly ? isExecutorAdmin : boolDefaultCondition,
channels ? channels.includes(channelId) : bDefCond, users ? users.includes(executor.id) : boolDefaultCondition,
member ? (roles ? roles.some(role => member.roles.cache.has(role)) : bDefCond) : bMemReqForUsers, member
member ? (permissions ? member.permissions.has(permissions) : bDefCond) : bMemReqForUsers, ? roles
users ? users.includes(executor.id) : bDefCond, ? roles.some(role => member.roles.cache.has(role))
: boolDefaultCondition
: boolMemberRequirementsForUsers,
member
? permissions
? member.permissions.has(permissions)
: boolDefaultCondition
: boolMemberRequirementsForUsers,
] ]
if (mode === 'all' && conditions.some(condition => !condition)) return false if (mode === 'all' && conditions.some(condition => !condition)) return false
@@ -320,14 +398,27 @@ export default class Command<
return true return true
} }
get json(): RESTPostAPIChatInputApplicationCommandsJSONBody & { contexts: Array<0 | 1 | 2> } { get json(): RESTPostAPIApplicationCommandsJSONBody {
return { // @ts-expect-error: I hate union types in TypeScript
const base: RESTPostAPIApplicationCommandsJSONBody = {
name: this.name, name: this.name,
type:
this.type === CommandType.ContextMenuGuildMessage || this.type === CommandType.ContextMenuMessage
? ApplicationCommandType.Message
: this.type === CommandType.ContextMenuGuildMember || this.type === CommandType.ContextMenuUser
? ApplicationCommandType.User
: ApplicationCommandType.ChatInput,
}
if (this.isContextMenuSpecific()) return base
return {
...base,
description: this.description, description: this.description,
options: this.options ? this.#transformOptions(this.options) : undefined, options: this.options ? this.#transformOptions(this.options) : undefined,
// https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-context-types // https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-interaction-context-types
contexts: this.global ? [0] : [0, 1], contexts: this.isGuildSpecific() ? [0] : [0, 1, 2],
} } as RESTPostAPIChatInputApplicationCommandsJSONBody & { contexts: Array<0 | 1 | 2> }
} }
#transformOptions(optionsObject: Record<string, CommandOption>) { #transformOptions(optionsObject: Record<string, CommandOption>) {
@@ -374,8 +465,8 @@ export default class Command<
export class ModerationCommand< export class ModerationCommand<
Options extends CommandOptionsOptions, Options extends CommandOptionsOptions,
AllowMessageCommand extends boolean = true, AllowMessageCommand extends boolean = true,
> extends Command<false, Options, AllowMessageCommand> { > extends Command<CommandType.ChatGuild, Options, AllowMessageCommand> {
constructor(options: ExtendedCommandOptions<false, Options, AllowMessageCommand>) { constructor(options: ExtendedCommandOptions<CommandType.ChatGuild, Options, AllowMessageCommand>) {
super({ super({
...options, ...options,
requirements: { requirements: {
@@ -385,17 +476,16 @@ export class ModerationCommand<
}, },
// @ts-expect-error: No thanks // @ts-expect-error: No thanks
allowMessageCommand: options.allowMessageCommand ?? true, allowMessageCommand: options.allowMessageCommand ?? true,
global: false, type: CommandType.ChatGuild,
}) })
} }
} }
export class AdminCommand<Options extends CommandOptionsOptions, AllowMessageCommand extends boolean> extends Command< export class AdminCommand<
true, Options extends CommandOptionsOptions,
Options, AllowMessageCommand extends boolean = true,
AllowMessageCommand > extends Command<CommandType.ChatGlobal, Options, AllowMessageCommand> {
> { constructor(options: ExtendedCommandOptions<CommandType.ChatGlobal, Options, AllowMessageCommand>) {
constructor(options: ExtendedCommandOptions<true, Options, AllowMessageCommand>) {
super({ super({
...options, ...options,
requirements: { requirements: {
@@ -403,38 +493,52 @@ export class AdminCommand<Options extends CommandOptionsOptions, AllowMessageCom
adminOnly: true, adminOnly: true,
defaultCondition: 'pass', defaultCondition: 'pass',
}, },
global: true, allowMessageCommand: options.allowMessageCommand ?? (true as AllowMessageCommand),
type: CommandType.ChatGlobal,
}) })
} }
} }
const fetchMember = async (
interaction: CommandInteraction<'raw' | 'cached'>,
source: UserResolvable = interaction.user,
manager = interaction.guild?.members,
) => {
const _manager = manager ?? (await interaction.client.guilds.fetch(interaction.guildId).then(it => it.members))
if (!_manager) throw new CommandError(CommandErrorType.FetchManagerNotFound, 'Cannot fetch member.')
return await _manager.fetch(source)
}
const fetchUser = (interaction: CommandInteraction, source: UserResolvable = interaction.user) => {
return interaction.client.users.fetch(source)
}
/* TODO: /* TODO:
APIApplicationCommandAttachmentOption APIApplicationCommandAttachmentOption
APIApplicationCommandMentionableOption APIApplicationCommandMentionableOption
APIApplicationCommandRoleOption APIApplicationCommandRoleOption
*/ */
export interface CommandOptions< export type CommandOptions<
Global extends boolean, Type extends CommandType,
Options extends CommandOptionsOptions | undefined, Options extends CommandOptionsOptions | undefined,
AllowMessageCommand extends boolean, AllowMessageCommand extends boolean,
> { > = {
name: string name: string
description: string
requirements?: CommandRequirements requirements?: CommandRequirements
options?: Options options?: Options
execute: CommandExecuteFunction<Global, Options, AllowMessageCommand> execute: CommandExecuteFunction<Type, Options, AllowMessageCommand>
global?: Global type?: Type
allowMessageCommand?: AllowMessageCommand allowMessageCommand?: AllowMessageCommand
} } & If<IsContextMenu<Type>, { description?: never }, { description: string }>
export type CommandArguments = Array<string | CommandSpecialArgument> export type CommandArguments = Array<string | CommandSpecialArgument>
export type CommandSpecialArgument = { export type CommandSpecialArgument = {
type: (typeof CommandSpecialArgumentType)[keyof typeof CommandSpecialArgumentType] type: (typeof CommandSpecialArgumentType)[keyof typeof CommandSpecialArgumentType]
id: string id: string
} }
//! If things ever get minified, this will most likely break property access via string names
export const CommandSpecialArgumentType = { export const CommandSpecialArgumentType = {
Channel: ApplicationCommandOptionType.Channel, Channel: ApplicationCommandOptionType.Channel,
Role: ApplicationCommandOptionType.Role, Role: ApplicationCommandOptionType.Role,
@@ -442,31 +546,56 @@ export const CommandSpecialArgumentType = {
} }
type ExtendedCommandOptions< type ExtendedCommandOptions<
Global extends boolean, Type extends CommandType,
Options extends CommandOptionsOptions, Options extends CommandOptionsOptions,
AllowMessageCommand extends boolean, AllowMessageCommand extends boolean,
> = Omit<CommandOptions<Global, Options, AllowMessageCommand>, 'global'> & { > = Omit<CommandOptions<Type, Options, AllowMessageCommand>, 'type'> & {
requirements?: Omit<CommandOptions<false, Options, AllowMessageCommand>['requirements'], 'defaultCondition'> requirements?: Omit<CommandOptions<Type, Options, AllowMessageCommand>['requirements'], 'defaultCondition'>
} }
export type CommandOptionsOptions = Record<string, CommandOption> export type CommandOptionsOptions = Record<string, CommandOption>
type ToCacheType<Type extends CommandType> = If<IsGuildSpecific<Type>, 'raw' | 'cached', CacheType>
type CommandExecuteFunction< type CommandExecuteFunction<
Global extends boolean, Type extends CommandType,
Options extends CommandOptionsOptions | undefined, Options extends CommandOptionsOptions | undefined,
AllowMessageCommand extends boolean, AllowMessageCommand extends boolean,
> = ( > = (
context: CommandContext<Global>, context: CommandContext<Type>,
trigger: If< trigger: If<
AllowMessageCommand, AllowMessageCommand,
Message<InvertBoolean<Global>> | ChatInputCommandInteraction<If<Global, CacheType, 'raw' | 'cached'>>, Message<IsGuildSpecific<Type>> | CommandTypeToInteractionMap<ToCacheType<Type>>[Type],
ChatInputCommandInteraction<If<Global, CacheType, 'raw' | 'cached'>> CommandTypeToInteractionMap<ToCacheType<Type>>[Type]
>, >,
options: Options extends CommandOptionsOptions ? CommandExecuteFunctionOptionsParameter<Options> : never, options: Options extends CommandOptionsOptions ? CommandExecuteFunctionOptionsParameter<Options> : never,
) => Promise<unknown> | unknown ) => Promise<unknown> | unknown
type CommandTypeToInteractionMap<CT extends CacheType> = {
[CommandType.ChatGlobal]: ChatInputCommandInteraction<CT>
[CommandType.ChatGuild]: ChatInputCommandInteraction<CT>
[CommandType.ContextMenuUser]: UserContextMenuCommandInteraction<CT>
[CommandType.ContextMenuMessage]: MessageContextMenuCommandInteraction<CT>
[CommandType.ContextMenuGuildMessage]: MessageContextMenuCommandInteraction<CT>
[CommandType.ContextMenuGuildMember]: MessageContextMenuCommandInteraction<CT>
}
type IsContextMenu<Type extends CommandType> = Extends<
Type,
| CommandType.ContextMenuGuildMessage
| CommandType.ContextMenuGuildMember
| CommandType.ContextMenuMessage
| CommandType.ContextMenuUser
>
type IsGuildSpecific<Type extends CommandType> = Extends<
Type,
CommandType.ChatGuild | CommandType.ContextMenuGuildMember | CommandType.ContextMenuGuildMessage
>
type Extends<T, U> = T extends U ? true : false
type If<T extends boolean | undefined, U, V> = T extends true ? U : V type If<T extends boolean | undefined, U, V> = T extends true ? U : V
type InvertBoolean<T extends boolean> = If<T, false, true> // type InvertBoolean<T extends boolean> = If<T, false, true>
type CommandExecuteFunctionOptionsParameter<Options extends CommandOptionsOptions> = { type CommandExecuteFunctionOptionsParameter<Options extends CommandOptionsOptions> = {
[K in keyof Options]: Options[K]['type'] extends [K in keyof Options]: Options[K]['type'] extends
@@ -481,8 +610,13 @@ type CommandExecuteFunctionOptionsParameter<Options extends CommandOptionsOption
> >
} }
type CommandContext<Global extends boolean> = typeof import('../context') & { type CommandContext<Type extends CommandType> = typeof import('../context') & {
executor: CommandExecutor<Global> executor: CommandExecutor<Type>
target: If<
Extends<Type, CommandType.ContextMenuGuildMember>,
GuildMember,
If<Extends<Type, CommandType.ContextMenuGuildMessage>, Message<true>, never>
>
} }
type CommandOptionValueMap = { type CommandOptionValueMap = {
@@ -508,7 +642,7 @@ type CommandOption =
| CommandSubcommandOption | CommandSubcommandOption
| CommandSubcommandGroupOption | CommandSubcommandGroupOption
type CommandExecutor<Global extends boolean> = If<Global, User, GuildMember> type CommandExecutor<Type extends CommandType> = If<IsGuildSpecific<Type>, GuildMember, User>
type CommandOptionBase<Type extends ApplicationCommandOptionType> = { type CommandOptionBase<Type extends ApplicationCommandOptionType> = {
type: Type type: Type
@@ -582,10 +716,12 @@ interface CommandSubcommandLikeOption<
type CommandSubcommandOption = CommandSubcommandLikeOption<ApplicationCommandOptionType.Subcommand> type CommandSubcommandOption = CommandSubcommandLikeOption<ApplicationCommandOptionType.Subcommand>
type CommandSubcommandGroupOption = CommandSubcommandLikeOption<ApplicationCommandOptionType.SubcommandGroup> type CommandSubcommandGroupOption = CommandSubcommandLikeOption<ApplicationCommandOptionType.SubcommandGroup>
export type CommandRequirements = Filter & { export type CommandRequirements = {
mode?: 'all' | 'any' users?: string[]
adminOnly?: boolean roles?: string[]
permissions?: bigint permissions?: bigint
adminOnly?: boolean
defaultCondition?: 'fail' | 'pass' defaultCondition?: 'fail' | 'pass'
memberRequirementsForUsers?: 'pass' | 'fail' memberRequirementsForUsers?: 'fail' | 'pass'
mode?: 'all' | 'any'
} }

View File

@@ -1,9 +1,9 @@
import { createErrorEmbed } from '$/utils/discord/embeds' import { createErrorEmbed } from '../utils/discord/embeds'
export default class CommandError extends Error { export default class CommandError extends Error {
type: CommandErrorType type: CommandErrorType
constructor(type: CommandErrorType, message?: string) { constructor(type: CommandErrorType, message: string = ErrorMessageMap[type]) {
super(message) super(message)
this.name = 'CommandError' this.name = 'CommandError'
this.type = type this.type = type
@@ -15,19 +15,34 @@ export default class CommandError extends Error {
} }
export enum CommandErrorType { export enum CommandErrorType {
Generic, Generic = 1,
InteractionNotInGuild,
InteractionDataMismatch,
FetchManagerNotFound,
FetchNotFound,
RequirementsNotMet = 100,
MissingArgument, MissingArgument,
InvalidArgument, InvalidArgument,
InvalidUser,
InvalidChannel,
InvalidDuration,
} }
const ErrorTitleMap: Record<CommandErrorType, string> = { const ErrorTitleMap: Record<CommandErrorType, string> = {
[CommandErrorType.Generic]: 'An exception was thrown', [CommandErrorType.Generic]: 'An exception was thrown',
[CommandErrorType.InteractionNotInGuild]: 'This command can only be used in servers',
[CommandErrorType.InteractionDataMismatch]: 'Command data mismatch',
[CommandErrorType.FetchManagerNotFound]: 'Cannot fetch data (manager not found)',
[CommandErrorType.FetchNotFound]: 'Cannot fetch data (source not found)',
[CommandErrorType.RequirementsNotMet]: 'Command requirements not met',
[CommandErrorType.MissingArgument]: 'Missing argument', [CommandErrorType.MissingArgument]: 'Missing argument',
[CommandErrorType.InvalidArgument]: 'Invalid argument', [CommandErrorType.InvalidArgument]: 'Invalid argument',
[CommandErrorType.InvalidUser]: 'Invalid user', }
[CommandErrorType.InvalidChannel]: 'Invalid channel',
[CommandErrorType.InvalidDuration]: 'Invalid duration', const ErrorMessageMap: Record<CommandErrorType, string> = {
[CommandErrorType.Generic]: 'An generic exception was thrown.',
[CommandErrorType.InteractionNotInGuild]: 'This command can only be used in servers.',
[CommandErrorType.InteractionDataMismatch]: 'Interaction command data does not match the expected command data.',
[CommandErrorType.FetchManagerNotFound]: 'Cannot fetch required data.',
[CommandErrorType.FetchNotFound]: 'Cannot fetch target.',
[CommandErrorType.RequirementsNotMet]: 'You do not meet the requirements to use this command.',
[CommandErrorType.MissingArgument]: 'You are missing a required argument.',
[CommandErrorType.InvalidArgument]: 'You provided an invalid argument.',
} }

View File

@@ -1,5 +1,4 @@
import { inspect } from 'util' import { inspect } from 'util'
import { runInNewContext } from 'vm'
import { ApplicationCommandOptionType } from 'discord.js' import { ApplicationCommandOptionType } from 'discord.js'
import { AdminCommand } from '$/classes/Command' import { AdminCommand } from '$/classes/Command'
@@ -21,12 +20,22 @@ export default new AdminCommand({
}, },
}, },
async execute(context, trigger, { code, 'show-hidden': showHidden }) { async execute(context, trigger, { code, 'show-hidden': showHidden }) {
// So it doesn't show up as unused, and we can use it in `code`
context
await trigger.reply({ await trigger.reply({
ephemeral: true, ephemeral: true,
embeds: [ embeds: [
createSuccessEmbed('Evaluate', `\`\`\`js\n${code}\`\`\``).addFields({ createSuccessEmbed('Evaluate', `\`\`\`js\n${code}\`\`\``).addFields({
name: 'Result', name: 'Result',
value: `\`\`\`js\n${inspect(runInNewContext(code, { client: trigger.client, context, trigger }), { depth: 1, showHidden, getters: true, numericSeparator: true, showProxy: true })}\`\`\``, // biome-ignore lint/security/noGlobalEval: This is fine as it's an admin command
value: `\`\`\`js\n${inspect(await eval(code), {
depth: 1,
showHidden,
getters: true,
numericSeparator: true,
showProxy: true,
})}\`\`\``,
}), }),
], ],
}) })

View File

@@ -0,0 +1,37 @@
import { AdminCommand } from '$/classes/Command'
import { join, dirname } from 'path'
import type { Config } from 'config.schema'
export default new AdminCommand({
name: 'reload',
description: 'Reload configuration',
async execute(context, trigger) {
const { api, logger, discord } = context
context.config = ((await import(join(dirname(Bun.main), '..', 'config.js'))) as { default: Config }).default
if ('deferReply' in trigger) await trigger.deferReply({ ephemeral: true })
logger.info('Reinitializing API client to reload configuration...')
await api.client.ws.setOptions(
{
url: context.config.api.url,
},
false,
)
api.intentionallyDisconnecting = true
api.client.disconnect(true)
api.disconnectCount = 0
api.intentionallyDisconnecting = false
await api.client.connect()
logger.info('Reinitializing Discord client to reload configuration...')
await discord.client.destroy()
// discord.client.token only gets set whenever a new Client is intialized
// so that's why we need to provide the token here :/
await discord.client.login(process.env['DISCORD_TOKEN'])
// @ts-expect-error: TypeScript dum
await trigger[('deferReply' in trigger ? 'editReply' : 'reply')]({ content: 'Reloaded configuration' })
},
})

View File

@@ -42,7 +42,7 @@ export default new AdminCommand({
const { global: globalCommands, guild: guildCommands } = Object.groupBy( const { global: globalCommands, guild: guildCommands } = Object.groupBy(
Object.values(context.discord.commands), Object.values(context.discord.commands),
cmd => (cmd.global ? 'global' : 'guild'), cmd => (cmd.isGuildSpecific() ? 'guild' : 'global'),
) )
const { const {

View File

@@ -6,7 +6,7 @@ import { applyCommonEmbedStyles } from '$/utils/discord/embeds'
export default new Command({ export default new Command({
name: 'coinflip', name: 'coinflip',
description: 'Do a coinflip!', description: 'Do a coinflip!',
global: true, type: Command.Type.ChatGlobal,
requirements: { requirements: {
defaultCondition: 'pass', defaultCondition: 'pass',
}, },

View File

@@ -35,7 +35,7 @@ export default new ModerationCommand({
if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidArgument,
'You cannot ban a user with a role equal to or higher than yours.', 'You cannot ban a user with a role equal to or higher than yours.',
) )
} }

View File

@@ -37,14 +37,14 @@ export default new ModerationCommand({
if (Number.isInteger(duration) && duration! < 1) if (Number.isInteger(duration) && duration! < 1)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidDuration, CommandErrorType.InvalidArgument,
'The duration must be at least 1 millisecond long.', 'The duration must be at least 1 millisecond long.',
) )
const expires = Math.max(duration, Date.now() + duration) const expires = Math.max(duration, Date.now() + duration)
if (!member) if (!member)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidArgument,
'The provided member is not in the server or does not exist.', 'The provided member is not in the server or does not exist.',
) )
@@ -53,14 +53,14 @@ export default new ModerationCommand({
if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidArgument,
'You cannot mute a user with a role equal to or higher than yours.', 'You cannot mute a user with a role equal to or higher than yours.',
) )
await applyRolePreset(member, 'mute', expires) await applyRolePreset(member, 'mute', expires)
await sendModerationReplyAndLogs( await sendModerationReplyAndLogs(
interaction, interaction,
createModerationActionEmbed('Muted', user, executor.user, reason, duration), createModerationActionEmbed('Muted', user, executor.user, reason, Math.ceil(expires / 1000)),
) )
if (duration) if (duration)

View File

@@ -1,4 +1,4 @@
import { EmbedBuilder, GuildChannel } from 'discord.js' import { EmbedBuilder } from 'discord.js'
import { ModerationCommand } from '$/classes/Command' import { ModerationCommand } from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError' import CommandError, { CommandErrorType } from '$/classes/CommandError'
@@ -31,8 +31,8 @@ export default new ModerationCommand({
throw new CommandError(CommandErrorType.MissingArgument, 'Either `amount` or `until` must be provided.') throw new CommandError(CommandErrorType.MissingArgument, 'Either `amount` or `until` must be provided.')
const channel = interaction.channel! const channel = interaction.channel!
if (!(channel.isTextBased() && channel instanceof GuildChannel)) if (!channel.isTextBased())
throw new CommandError(CommandErrorType.InvalidChannel, 'The supplied channel is not a text channel.') throw new CommandError(CommandErrorType.InvalidArgument, 'The supplied channel is not a text channel.')
const embed = applyCommonEmbedStyles( const embed = applyCommonEmbedStyles(
new EmbedBuilder({ new EmbedBuilder({

View File

@@ -45,7 +45,7 @@ export default new ModerationCommand({
if (!member) if (!member)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidArgument,
'The provided member is not in the server or does not exist.', 'The provided member is not in the server or does not exist.',
) )
@@ -56,13 +56,13 @@ export default new ModerationCommand({
const duration = durationInput ? parseDuration(durationInput) : Infinity const duration = durationInput ? parseDuration(durationInput) : Infinity
if (Number.isInteger(duration) && duration! < 1) if (Number.isInteger(duration) && duration! < 1)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidDuration, CommandErrorType.InvalidArgument,
'The duration must be at least 1 millisecond long.', 'The duration must be at least 1 millisecond long.',
) )
if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0) if (moderator.roles.highest.comparePositionTo(member.roles.highest) <= 0)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidArgument,
'You cannot apply a role preset to a user with a role equal to or higher than yours.', 'You cannot apply a role preset to a user with a role equal to or higher than yours.',
) )
@@ -83,6 +83,6 @@ export default new ModerationCommand({
removeRolePreset(member, preset) removeRolePreset(member, preset)
}, expires) }, expires)
await sendPresetReplyAndLogs(apply ? 'apply' : 'remove', trigger, executor, user, preset, expires) await sendPresetReplyAndLogs(apply ? 'apply' : 'remove', trigger, executor, user, preset, expires ? Math.ceil(expires / 1000) : undefined)
}, },
}) })

View File

@@ -27,14 +27,14 @@ export default new ModerationCommand({
if (!channel?.isTextBased() || channel.isDMBased()) if (!channel?.isTextBased() || channel.isDMBased())
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidChannel, CommandErrorType.InvalidArgument,
'The supplied channel is not a text channel or does not exist.', 'The supplied channel is not a text channel.',
) )
if (Number.isNaN(duration)) throw new CommandError(CommandErrorType.InvalidDuration, 'Invalid duration.') if (Number.isNaN(duration)) throw new CommandError(CommandErrorType.InvalidArgument, 'Invalid duration.')
if (duration < 0 || duration > 36e4) if (duration < 0 || duration > 36e4)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidDuration, CommandErrorType.InvalidArgument,
'Duration out of range, must be between 0s and 6h.', 'Duration out of range, must be between 0s and 6h.',
) )

View File

@@ -20,7 +20,7 @@ export default new ModerationCommand({
const member = await interaction.guild!.members.fetch(user.id) const member = await interaction.guild!.members.fetch(user.id)
if (!member) if (!member)
throw new CommandError( throw new CommandError(
CommandErrorType.InvalidUser, CommandErrorType.InvalidArgument,
'The provided member is not in the server or does not exist.', 'The provided member is not in the server or does not exist.',
) )

View File

@@ -0,0 +1,76 @@
import Command from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { config } from '../../../context'
import type { FetchMessageOptions, MessageResolvable } from 'discord.js'
import type { ConfigMessageScanResponseLabelConfig } from 'config.schema'
import { createSuccessEmbed } from '$/utils/discord/embeds'
const msRcConfig = config.messageScan?.humanCorrections?.allow
export default new Command({
name: 'train',
description: 'Train a specific message or text to a specific label',
type: Command.Type.ChatGuild,
requirements: {
users: msRcConfig?.users,
roles: msRcConfig?.members?.roles,
permissions: msRcConfig?.members?.permissions,
mode: 'any',
memberRequirementsForUsers: 'fail',
defaultCondition: 'fail',
},
options: {
message: {
description: 'The message to train (use `latest` to train the latest message)',
type: Command.OptionType.String,
required: true,
},
label: {
description: 'The label to train the message as',
type: Command.OptionType.String,
required: true,
},
},
allowMessageCommand: true,
async execute(context, trigger, { label, message: ref }) {
const { logger, config } = context
const { messageScan: msConfig } = config
// If there's no config, we can't do anything
if (!msConfig?.humanCorrections) throw new CommandError(CommandErrorType.Generic, 'Response correction is off.')
const labels = msConfig.responses?.flatMap(r =>
r.triggers.text!.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label),
)
const channel = await trigger.guild!.channels.fetch(trigger.channelId)
if (!channel?.isTextBased())
throw new CommandError(
CommandErrorType.InvalidArgument,
'This command can only be used in or on text channels',
)
if (!labels.includes(label))
throw new CommandError(
CommandErrorType.InvalidArgument,
`The provided label is invalid.\nValid labels are:${labels.map(l => `\n- \`${l}\``).join('')}`,
)
const refMsg = await channel.messages.fetch(
(ref.startsWith('latest') ? { limit: 1 } : ref) as MessageResolvable | FetchMessageOptions,
)
if (!refMsg) throw new CommandError(CommandErrorType.InvalidArgument, 'The provided message does not exist.')
logger.debug(`User ${context.executor.id} is training message ${refMsg?.id} as ${label}`)
await context.api.client.trainMessage(refMsg.content, label)
await trigger.reply({
embeds: [
createSuccessEmbed(
'Message trained',
`The provided message has been trained as \`${label}\`. Thank you for your contribution!`,
),
],
ephemeral: true,
})
},
})

View File

@@ -0,0 +1,50 @@
import Command from '$/classes/Command'
import CommandError, { CommandErrorType } from '$/classes/CommandError'
import { config } from '../../../context'
import { type APIStringSelectComponent, ComponentType } from 'discord.js'
import type { ConfigMessageScanResponseLabelConfig } from 'config.schema'
const msRcConfig = config.messageScan?.humanCorrections?.allow
export default new Command({
name: 'Train Message',
type: Command.Type.ContextMenuGuildMessage,
requirements: {
users: msRcConfig?.users,
roles: msRcConfig?.members?.roles,
permissions: msRcConfig?.members?.permissions,
mode: 'any',
memberRequirementsForUsers: 'fail',
defaultCondition: 'fail',
},
async execute(context, trigger) {
const { logger, config } = context
const { messageScan: msConfig } = config
// If there's no config, we can't do anything
if (!msConfig?.humanCorrections) throw new CommandError(CommandErrorType.Generic, 'Response correction is off.')
logger.debug(`User ${context.executor.id} is training message ${trigger.targetId}`)
const labels = msConfig.responses.flatMap(r =>
r.triggers.text!.filter((t): t is ConfigMessageScanResponseLabelConfig => 'label' in t).map(t => t.label),
)
await trigger.reply({
content: 'Select a label to train this message as:',
components: [
{
components: [
{
custom_id: `tr_${trigger.targetMessage.channelId}_${trigger.targetId}`,
options: labels.map(label => ({ label, value: label })),
type: ComponentType.StringSelect,
} satisfies APIStringSelectComponent,
],
type: ComponentType.ActionRow,
},
],
ephemeral: true,
})
},
})

View File

@@ -4,12 +4,6 @@ export const MessageScanLabeledResponseReactions = {
delete: '❌', delete: '❌',
} as const } as const
export const MessageScanHumanizedMode = {
ocr: 'image recognition',
nlp: 'text analysis',
match: 'pattern matching',
} as const
export const DefaultEmbedColor = '#4E98F0' export const DefaultEmbedColor = '#4E98F0'
export const ReVancedLogoURL = export const ReVancedLogoURL =
'https://media.discordapp.net/attachments/1095487869923119144/1115436493050224660/revanced-logo.png' 'https://media.discordapp.net/attachments/1095487869923119144/1115436493050224660/revanced-logo.png'

View File

@@ -3,7 +3,7 @@ import { existsSync, readFileSync, readdirSync } from 'fs'
import { join } from 'path' import { join } from 'path'
import { Client as APIClient } from '@revanced/bot-api' import { Client as APIClient } from '@revanced/bot-api'
import { createLogger } from '@revanced/bot-shared' import { createLogger } from '@revanced/bot-shared'
import { Client as DiscordClient, Partials } from 'discord.js' import { Client as DiscordClient, type Message, Partials } from 'discord.js'
import { drizzle } from 'drizzle-orm/bun-sqlite' import { drizzle } from 'drizzle-orm/bun-sqlite'
// Export some things first, as commands require them // Export some things first, as commands require them
@@ -17,7 +17,7 @@ export const logger = createLogger({
import * as commands from './commands' import * as commands from './commands'
import * as schemas from './database/schemas' import * as schemas from './database/schemas'
import type { default as Command, CommandOptionsOptions } from './classes/Command' import type { default as Command, CommandOptionsOptions, CommandType } from './classes/Command'
export const api = { export const api = {
client: new APIClient({ client: new APIClient({
@@ -56,7 +56,7 @@ if (DatabasePath && !existsSync(DatabasePath)) {
} }
} }
const db = new Database(DatabasePath) const db = new Database(DatabasePath, { readwrite: true, create: true })
if (dbSchemaFileName) db.run(readFileSync(join(DatabaseSchemaDir, dbSchemaFileName)).toString()) if (dbSchemaFileName) db.run(readFileSync(join(DatabaseSchemaDir, dbSchemaFileName)).toString())
export const database = drizzle(db, { export const database = drizzle(db, {
@@ -83,6 +83,28 @@ export const discord = {
}), }),
commands: Object.fromEntries(Object.values(commands).map(cmd => [cmd.name, cmd])) as Record< commands: Object.fromEntries(Object.values(commands).map(cmd => [cmd.name, cmd])) as Record<
string, string,
Command<boolean, CommandOptionsOptions | undefined, boolean> Command<CommandType, CommandOptionsOptions | undefined, boolean>
>,
stickyMessages: {} as Record<
string,
Record<
string,
{
/**
* Chat is active, so force send timer is also active
*/
forceTimerActive: boolean
/**
* There was a message sent, so the timer is active
*/
timerActive: boolean
timerMs: number
forceTimerMs?: number
send: (forced?: boolean) => Promise<void>
currentMessage?: Message<true>
timer?: NodeJS.Timeout
forceTimer?: NodeJS.Timeout
}
>
>, >,
} as const } as const

View File

@@ -16,6 +16,7 @@ export const appliedPresets = sqliteTable(
{ {
memberId: text('member').notNull(), memberId: text('member').notNull(),
guildId: text('guild').notNull(), guildId: text('guild').notNull(),
removedRoles: text('roles', { mode: 'json' }).notNull().$type<string[]>().default([]),
preset: text('preset').notNull(), preset: text('preset').notNull(),
until: integer('until'), until: integer('until'),
}, },

View File

@@ -1,3 +1,7 @@
import { on, withContext } from '$utils/api/events' import { on, withContext } from '$utils/api/events'
withContext(on, 'ready', ({ logger }) => void logger.info('Connected to the bot API')) withContext(on, 'ready', ({ api, logger }) => {
// Reset disconnect count, so it doesn't meet the threshold for an accidental disconnect
api.disconnectCount = 0
logger.info('Connected to the bot API')
})

View File

@@ -12,5 +12,5 @@ withContext(on, 'guildMemberAdd', async ({ database }, member) => {
), ),
}) })
for (const { preset } of applieds) await applyRolesUsingPreset(preset, member, true) for (const { preset } of applieds) await applyRolesUsingPreset(preset, member)
}) })

View File

@@ -8,18 +8,24 @@ withContext(on, 'interactionCreate', async (context, interaction) => {
const { logger, discord } = context const { logger, discord } = context
const command = discord.commands[interaction.commandName] const command = discord.commands[interaction.commandName]
logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag}`) logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag} via chat`)
if (!command) return void logger.error(`Command ${interaction.commandName} not implemented but registered!!!`) if (!command)
return void logger.error(`Chat command ${interaction.commandName} not implemented but registered!!!`)
try { try {
logger.debug(`Command ${interaction.commandName} being executed`) logger.debug(`Command ${interaction.commandName} being executed via chat`)
await command.onInteraction(context, interaction) await command.onInteraction(context, interaction)
} catch (err) { } catch (err) {
if (!(err instanceof CommandError)) if (!(err instanceof CommandError))
logger.error(`Error while executing command ${interaction.commandName}:`, err) logger.error(`Error while executing command ${interaction.commandName}:`, err)
await interaction[interaction.replied ? 'followUp' : 'reply']({ await interaction[interaction.replied ? 'followUp' : 'reply']({
embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)], embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)],
ephemeral: true, ephemeral: true,
}) })
// 100 and up are user errors
if (err instanceof CommandError && err.type < 100)
logger.error(`Command ${interaction.commandName} internally failed with error:`, err)
} }
}) })

View File

@@ -0,0 +1,26 @@
import CommandError from '$/classes/CommandError'
import { createStackTraceEmbed } from '$utils/discord/embeds'
import { on, withContext } from '$utils/discord/events'
withContext(on, 'interactionCreate', async (context, interaction) => {
if (!interaction.isContextMenuCommand()) return
const { logger, discord } = context
const command = discord.commands[interaction.commandName]
logger.debug(`Command ${interaction.commandName} being invoked by ${interaction.user.tag} via context menu`)
if (!command)
return void logger.error(`Context menu command ${interaction.commandName} not implemented but registered!!!`)
try {
logger.debug(`Command ${interaction.commandName} being executed via context menu`)
await command.onContextMenuInteraction(context, interaction)
} catch (err) {
if (!(err instanceof CommandError))
logger.error(`Error while executing command ${interaction.commandName}:`, err)
await interaction[interaction.replied ? 'followUp' : 'reply']({
embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)],
ephemeral: true,
})
}
})

View File

@@ -18,10 +18,10 @@ withContext(on, 'interactionCreate', async (context, interaction) => {
if (!interaction.isStringSelectMenu() && !interaction.isButton()) return if (!interaction.isStringSelectMenu() && !interaction.isButton()) return
if (!interaction.customId.startsWith('cr_')) return if (!interaction.customId.startsWith('cr_')) return
const [, key, action] = interaction.customId.split('_') as ['cr', string, 'select' | 'cancel' | 'delete'] const [, msgId, action] = interaction.customId.split('_') as ['cr', string, 'select' | 'cancel' | 'delete']
if (!key || !action) return if (!msgId || !action) return
const response = await db.query.responses.findFirst({ where: eq(responses.replyId, key) }) const response = await db.query.responses.findFirst({ where: eq(responses.replyId, msgId) })
// If the message isn't saved in my DB (unrelated message) // If the message isn't saved in my DB (unrelated message)
if (!response) if (!response)
return void (await interaction.reply({ return void (await interaction.reply({
@@ -32,11 +32,11 @@ withContext(on, 'interactionCreate', async (context, interaction) => {
try { try {
// We're gonna pretend reactionChannel is a text-based channel, but it can be many more // We're gonna pretend reactionChannel is a text-based channel, but it can be many more
// But `messages` should always exist as a property // But `messages` should always exist as a property
const reactionGuild = await interaction.client.guilds.fetch(response.guildId) const guild = await interaction.client.guilds.fetch(response.guildId)
const reactionChannel = (await reactionGuild.channels.fetch(response.channelId)) as TextBasedChannel | null const channel = (await guild.channels.fetch(response.channelId)) as TextBasedChannel | null
const reactionMessage = await reactionChannel?.messages.fetch(key) const msg = await channel?.messages.fetch(msgId)
if (!reactionMessage) { if (!msg) {
await interaction.deferUpdate() await interaction.deferUpdate()
await interaction.message.edit({ await interaction.message.edit({
content: null, content: null,
@@ -53,9 +53,9 @@ withContext(on, 'interactionCreate', async (context, interaction) => {
} }
const editMessage = (content: string, description?: string) => const editMessage = (content: string, description?: string) =>
editInteractionMessage(interaction, reactionMessage.url, content, description) editInteractionMessage(interaction, msg.url, content, description)
const handleCorrection = (label: string) => const handleCorrection = (label: string) =>
handleUserResponseCorrection(context, response, reactionMessage, label, interaction.user) handleUserResponseCorrection(context, response, msg, label, interaction.user)
if (response.correctedById) if (response.correctedById)
return await editMessage( return await editMessage(

View File

@@ -0,0 +1,52 @@
import { createErrorEmbed, createStackTraceEmbed, createSuccessEmbed } from '$utils/discord/embeds'
import { on, withContext } from '$utils/discord/events'
import type { TextBasedChannel } from 'discord.js'
withContext(on, 'interactionCreate', async (context, interaction) => {
const {
logger,
config: { messageScan: msConfig },
} = context
if (!msConfig?.humanCorrections) return
if (!interaction.isStringSelectMenu()) return
if (!interaction.customId.startsWith('tr_')) return
const [, channelId, msgId] = interaction.customId.split('_') as ['tr', string, string]
if (!channelId || !msgId) return
try {
const channel = (await interaction.client.channels.fetch(channelId)) as TextBasedChannel | null
const msg = await channel?.messages.fetch(msgId)
if (!msg)
return void (await interaction.reply({
embeds: [
createErrorEmbed(
'Message not found',
'Thank you for your contribution! Unfortunately, the message could not be found.',
),
],
ephemeral: true,
}))
const selectedLabel = interaction.values[0]!
await context.api.client.trainMessage(msg.content, selectedLabel)
await interaction.reply({
embeds: [
createSuccessEmbed(
'Message being trained',
`Thank you for your contribution! The selected message is being trained as \`${selectedLabel}\`. 🎉`,
),
],
ephemeral: true,
})
} catch (e) {
logger.error('Failed to handle train message interaction:', e)
await interaction.reply({
embeds: [createStackTraceEmbed(e)],
ephemeral: true,
})
}
})

View File

@@ -8,7 +8,9 @@ withContext(on, 'messageCreate', async (context, msg) => {
if (msg.author.bot) return if (msg.author.bot) return
const regex = new RegExp(`^(?:${config.prefix}|${msg.client.user.toString()}\\s*)([a-zA-Z-_]+)(?:\\s+)?(.+)?`) const regex = new RegExp(
`^(?:${config.prefix ? `${escapeRegexSpecials(config.prefix)}|` : ''}${msg.client.user.toString()}\\s*)([a-zA-Z-_]+)(?:\\s+)?(.+)?`,
)
const matches = msg.content.match(regex) const matches = msg.content.match(regex)
if (!matches) return if (!matches) return
@@ -16,8 +18,8 @@ withContext(on, 'messageCreate', async (context, msg) => {
if (!commandName) return if (!commandName) return
const command = discord.commands[commandName] const command = discord.commands[commandName]
logger.debug(`Command ${commandName} being invoked by ${msg.author.id}`) logger.debug(`Command ${commandName} being invoked by ${msg.author.id} via message`)
if (!command) return void logger.error(`Command ${commandName} not implemented`) if (!command) return void logger.debug(`Message command ${commandName} not implemented`)
const argsRegex: RegExp = /[^\s"]+|"([^"]*)"/g const argsRegex: RegExp = /[^\s"]+|"([^"]*)"/g
const args: CommandArguments = [] const args: CommandArguments = []
@@ -44,10 +46,19 @@ withContext(on, 'messageCreate', async (context, msg) => {
} }
try { try {
logger.debug(`Command ${commandName} being executed`) logger.debug(`Command ${commandName} being executed via message`)
await command.onMessage(context, msg, args) await command.onMessage(context, msg, args)
} catch (err) { } catch (err) {
if (!(err instanceof CommandError)) logger.error(`Error while executing command ${commandName}:`, err) if (!(err instanceof CommandError)) logger.error(`Error while executing command ${commandName}:`, err)
await msg.reply({ embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)] }) await msg.reply({ embeds: [err instanceof CommandError ? err.toEmbed() : createStackTraceEmbed(err)] })
} }
}) })
const escapeRegexSpecials = (str: string): string => {
let escapedStr = ''
for (const char of str) {
if (['.', '+', '*', '?', '$', '(', ')', '[', ']', '{', '}', '|', '\\'].includes(char)) escapedStr += `\\${char}`
else escapedStr += char
}
return escapedStr
}

View File

@@ -24,7 +24,7 @@ withContext(on, 'messageCreate', async (context, msg) => {
try { try {
logger.debug(`Classifying message ${msg.id}`) logger.debug(`Classifying message ${msg.id}`)
const { response, label, replyToReplied } = await getResponseFromText( const { response, label, respondToReply } = await getResponseFromText(
msg.content, msg.content,
filteredResponses, filteredResponses,
context, context,
@@ -33,14 +33,14 @@ withContext(on, 'messageCreate', async (context, msg) => {
if (response) { if (response) {
logger.debug('Response found') logger.debug('Response found')
const toReply = replyToReplied ? await msg.fetchReference() : msg const toReply = respondToReply ? (msg.reference?.messageId ? await msg.fetchReference() : msg) : msg
const reply = await toReply.reply({ const reply = await toReply.reply({
...response, ...response,
embeds: response.embeds?.map(it => createMessageScanResponseEmbed(it, label ? 'nlp' : 'match')), embeds: response.embeds?.map(createMessageScanResponseEmbed),
}) })
if (label) if (label) {
db.insert(responses).values({ await db.insert(responses).values({
replyId: reply.id, replyId: reply.id,
channelId: reply.channel.id, channelId: reply.channel.id,
guildId: reply.guild!.id, guildId: reply.guild!.id,
@@ -49,7 +49,6 @@ withContext(on, 'messageCreate', async (context, msg) => {
content: msg.content, content: msg.content,
}) })
if (label) {
for (const reaction of Object.values(MessageScanLabeledResponseReactions)) { for (const reaction of Object.values(MessageScanLabeledResponseReactions)) {
await reply.react(reaction) await reply.react(reaction)
} }
@@ -60,27 +59,50 @@ withContext(on, 'messageCreate', async (context, msg) => {
} }
} }
if (msg.attachments.size > 0) { if (msg.attachments.size && config.attachments?.scanAttachments) {
logger.debug(`Classifying message attachments for ${msg.id}`) logger.debug(`Classifying message attachments for ${msg.id}`)
for (const attachment of msg.attachments.values()) { for (const attachment of msg.attachments.values()) {
if (attachment.contentType && !config.allowedAttachmentMimeTypes.includes(attachment.contentType)) continue const mimeType = attachment.contentType?.split(';')?.[0]
if (!mimeType) return void logger.warn(`No MIME type for attachment: ${attachment.url}`)
if (
config.attachments.allowedMimeTypes &&
!config.attachments.allowedMimeTypes.includes(mimeType)
) {
logger.debug(`Disallowed MIME type for attachment: ${attachment.url}, ${mimeType}`)
continue
}
const isTextFile = mimeType.startsWith('text/')
if (isTextFile && attachment.size > (config.attachments.maxTextFileSize ?? 512 * 1000)) {
logger.debug(`Attachment ${attachment.url} is too large be to scanned, size is ${attachment.size}`)
continue
}
try { try {
const { text: content } = await api.client.parseImage(attachment.url) let response: Awaited<ReturnType<typeof getResponseFromText>>['response'] | undefined
const { response } = await getResponseFromText(content, filteredResponses, context, true)
if (isTextFile) {
const content = await (await fetch(attachment.url)).text()
response = await getResponseFromText(content, filteredResponses, context, { skipApiRequest: true }).then(it => it.response)
} else {
const { text: content } = await api.client.parseImage(attachment.url)
response = await getResponseFromText(content, filteredResponses, context, { onlyImageTriggers: true }).then(it => it.response)
}
if (response) { if (response) {
logger.debug(`Response found for attachment: ${attachment.url}`) logger.debug(`Response found for attachment: ${attachment.url}`)
await msg.reply({ await msg.reply({
...response, ...response,
embeds: response.embeds?.map(it => createMessageScanResponseEmbed(it, 'ocr')), embeds: response.embeds?.map(createMessageScanResponseEmbed),
}) })
break break
} }
} catch { } catch (e) {
logger.error(`Failed to parse image: ${attachment.url}`) logger.error(`Failed to parse attachment: ${attachment.url}`, e)
} }
} }
} }

View File

@@ -0,0 +1,38 @@
import { on, withContext } from '$utils/discord/events'
withContext(on, 'messageCreate', async ({ discord, logger }, msg) => {
if (!msg.inGuild()) return
if (msg.author.id === msg.client.user.id) return
const store = discord.stickyMessages[msg.guildId]?.[msg.channelId]
if (!store) return
const timerPreviouslyActive = store.timerActive
// If there isn't a timer, start it up
store.timerActive = true
if (!store.timer) store.timer = setTimeout(store.send, store.timerMs) as NodeJS.Timeout
else {
// If there is a timer, but it isn't active, restart it
if (!timerPreviouslyActive) store.timer.refresh()
// If there is a timer and it is active, but the force timer isn't active...
else if (!store.forceTimerActive && store.forceTimerMs) {
logger.debug(`Channel ${msg.channelId} in guild ${msg.guildId} is active, starting force send timer and clearing existing timer`)
// Clear the timer
clearTimeout(store.timer)
store.timerActive = false
store.forceTimerActive = true
// (Re)start the force timer
if (!store.forceTimer)
store.forceTimer = setTimeout(
() =>
store.send(true).then(() => {
store.forceTimerActive = false
}),
store.forceTimerMs,
) as NodeJS.Timeout
else store.forceTimer.refresh()
}
}
})

View File

@@ -20,10 +20,10 @@ const PossibleReactions = Object.values(Reactions) as string[]
withContext(on, 'messageReactionAdd', async (context, rct, user) => { withContext(on, 'messageReactionAdd', async (context, rct, user) => {
if (user.bot) return if (user.bot) return
const { database: db, logger, config } = context const { database: db, logger, config } = context
const { messageScan: msConfig } = config const { messageScan: msConfig } = config
// If there's no config, we can't do anything // If there's no config, we can't do anything
if (!msConfig?.humanCorrections) return if (!msConfig?.humanCorrections) return
@@ -32,13 +32,11 @@ withContext(on, 'messageReactionAdd', async (context, rct, user) => {
if (reactionMessage.author.id !== reaction.client.user!.id) return if (reactionMessage.author.id !== reaction.client.user!.id) return
if (!PossibleReactions.includes(reaction.emoji.name!)) return if (!PossibleReactions.includes(reaction.emoji.name!)) return
await rct.users.remove(user.id)
if (!isAdmin(reactionMessage.member || reactionMessage.author)) { if (!isAdmin(reactionMessage.member || reactionMessage.author)) {
// User is in guild, and config has member requirements // User is in guild, and config has member requirements
if ( if (reactionMessage.inGuild() && msConfig.humanCorrections.allow) {
reactionMessage.inGuild() &&
(msConfig.humanCorrections.allow?.members || msConfig.humanCorrections.allow?.users)
) {
const { const {
allow: { users: allowedUsers, members: allowedMembers }, allow: { users: allowedUsers, members: allowedMembers },
} = msConfig.humanCorrections } = msConfig.humanCorrections
@@ -54,20 +52,19 @@ withContext(on, 'messageReactionAdd', async (context, rct, user) => {
) )
) )
return return
} else if (allowedUsers) { } else if (!allowedUsers?.includes(user.id)) return
if (!allowedUsers.includes(user.id)) return } else
} else { return void logger.warn(
return void logger.warn( 'No member or user requirements set for human corrections, all requests will be ignored',
'No member or user requirements set for human corrections, all requests will be ignored', )
)
}
}
} }
// Sanity check // Sanity check
const response = await db.query.responses.findFirst({ where: eq(responses.replyId, rct.message.id) }) const response = await db.query.responses.findFirst({ where: eq(responses.replyId, rct.message.id) })
if (!response || response.correctedById) return if (!response || response.correctedById) return
logger.debug(`User ${user.id} is trying to correct the response ${rct.message.id}`)
const handleCorrection = (label: string) => const handleCorrection = (label: string) =>
handleUserResponseCorrection(context, response, reactionMessage, label, user) handleUserResponseCorrection(context, response, reactionMessage, label, user)

View File

@@ -1,14 +1,82 @@
import { database, logger } from '$/context' import { database, logger } from '$/context'
import { appliedPresets } from '$/database/schemas' import { appliedPresets } from '$/database/schemas'
import { applyCommonEmbedStyles } from '$/utils/discord/embeds'
import { on, withContext } from '$/utils/discord/events'
import { removeRolePreset } from '$/utils/discord/rolePresets' import { removeRolePreset } from '$/utils/discord/rolePresets'
import type { Client } from 'discord.js'
import { lt } from 'drizzle-orm' import { lt } from 'drizzle-orm'
import { on, withContext } from 'src/utils/discord/events'
export default withContext(on, 'ready', ({ config, logger }, client) => { import type { Client } from 'discord.js'
export default withContext(on, 'ready', async ({ config, discord, logger }, client) => {
logger.info(`Connected to Discord API, logged in as ${client.user.displayName} (@${client.user.tag})!`) logger.info(`Connected to Discord API, logged in as ${client.user.displayName} (@${client.user.tag})!`)
logger.info(`Bot is in ${client.guilds.cache.size} guilds`) logger.info(`Bot is in ${client.guilds.cache.size} guilds`)
if (config.stickyMessages)
for (const [guildId, channels] of Object.entries(config.stickyMessages)) {
const guild = await client.guilds.fetch(guildId)
// In case of configuration refresh, this will not be nullable
const oldStore = discord.stickyMessages[guildId]
discord.stickyMessages[guildId] = {}
for (const [channelId, { message, timeout, forceSendTimeout }] of Object.entries(channels)) {
const channel = await guild.channels.fetch(channelId)
if (!channel?.isTextBased())
return void logger.warn(
`Channel ${channelId} in guild ${guildId} is not a text channel, sticky messages will not be sent`,
)
const send = async (forced = false) => {
try {
const msg = await channel.send({
...message,
embeds: message.embeds?.map(it => applyCommonEmbedStyles(it, true, true, true)),
})
const store = discord.stickyMessages[guildId]![channelId]
if (!store) return
await store.currentMessage?.delete().catch()
store.currentMessage = msg
// Clear any remaining timers
clearTimeout(store.timer)
clearTimeout(store.forceTimer)
store.forceTimerActive = store.timerActive = false
if (!forced)
logger.debug(
`Timeout ended for sticky message in channel ${channelId} in guild ${guildId}, channel is inactive`,
)
else
logger.debug(
`Forced send timeout for sticky message in channel ${channelId} in guild ${guildId} ended, channel is too active`,
)
logger.debug(`Sent sticky message to channel ${channelId} in guild ${guildId}`)
} catch (e) {
logger.error(
`Error while sending sticky message to channel ${channelId} in guild ${guildId}:`,
e,
)
}
}
// Set up the store
discord.stickyMessages[guildId]![channelId] = {
forceTimerActive: false,
timerActive: false,
forceTimerMs: forceSendTimeout,
timerMs: timeout,
send,
// If the store exists before the configuration refresh, take its current message
currentMessage: oldStore?.[channelId]?.currentMessage
}
// Send a new sticky message immediately, as well as deleting the old/outdated message, if it exists
await send()
}
}
if (config.rolePresets) { if (config.rolePresets) {
removeExpiredPresets(client) removeExpiredPresets(client)
setTimeout(() => removeExpiredPresets(client), config.rolePresets.checkExpiredEvery) setTimeout(() => removeExpiredPresets(client), config.rolePresets.checkExpiredEvery)

View File

@@ -10,5 +10,10 @@ if (missingEnvs.length) {
process.exit(1) process.exit(1)
} }
// Handle uncaught exceptions
process.on('uncaughtException', error => console.error('Uncaught exception:', error))
process.on('unhandledRejection', reason => console.error('Unhandled rejection:', reason))
api.client.connect() api.client.connect()
discord.client.login() discord.client.login()

View File

@@ -1,5 +1,5 @@
import { DefaultEmbedColor, MessageScanHumanizedMode, ReVancedLogoURL } from '$/constants' import { DefaultEmbedColor, ReVancedLogoURL } from '$/constants'
import { EmbedBuilder, type EmbedField, type User } from 'discord.js' import { type APIEmbed, EmbedBuilder, type EmbedField, type JSONEncodable, type User } from 'discord.js'
import type { ConfigMessageScanResponseMessage } from '../../../config.schema' import type { ConfigMessageScanResponseMessage } from '../../../config.schema'
export const createErrorEmbed = (title: string | null, description?: string) => export const createErrorEmbed = (title: string | null, description?: string) =>
@@ -25,23 +25,7 @@ export const createSuccessEmbed = (title: string | null, description?: string) =
export const createMessageScanResponseEmbed = ( export const createMessageScanResponseEmbed = (
response: NonNullable<ConfigMessageScanResponseMessage['embeds']>[number], response: NonNullable<ConfigMessageScanResponseMessage['embeds']>[number],
mode: 'ocr' | 'nlp' | 'match', ) => applyCommonEmbedStyles(response, true, true, true)
) => {
// biome-ignore lint/style/noParameterAssign: While this is confusing, it is fine for this purpose
if ('toJSON' in response) response = response.toJSON()
const embed = new EmbedBuilder().setTitle(response.title ?? null)
if (response.description) embed.setDescription(response.description)
if (response.fields) embed.addFields(response.fields)
embed.setFooter({
text: `ReVanced • Via ${MessageScanHumanizedMode[mode]}`,
iconURL: ReVancedLogoURL,
})
return applyCommonEmbedStyles(embed, true, true, true)
}
export const createModerationActionEmbed = ( export const createModerationActionEmbed = (
action: string, action: string,
@@ -77,19 +61,23 @@ export const applyReferenceToModerationActionEmbed = (embed: EmbedBuilder, refer
} }
export const applyCommonEmbedStyles = ( export const applyCommonEmbedStyles = (
embed: EmbedBuilder, embed: EmbedBuilder | JSONEncodable<APIEmbed> | APIEmbed,
setThumbnail = false, setThumbnail = false,
setFooter = false, setFooter = false,
setColor = false, setColor = false,
) => { ) => {
// biome-ignore lint/style/noParameterAssign: While this is confusing, it is fine for this purpose
if ('toJSON' in embed) embed = embed.toJSON()
const builder = new EmbedBuilder(embed)
if (setFooter) if (setFooter)
embed.setFooter({ builder.setFooter({
text: 'ReVanced', text: 'ReVanced',
iconURL: ReVancedLogoURL, iconURL: ReVancedLogoURL,
}) })
if (setColor) embed.setColor(DefaultEmbedColor) if (setColor) builder.setColor(DefaultEmbedColor)
if (setThumbnail) embed.setThumbnail(ReVancedLogoURL) if (setThumbnail) builder.setThumbnail(ReVancedLogoURL)
return embed return builder
} }

View File

@@ -9,10 +9,13 @@ export const getResponseFromText = async (
responses: ConfigMessageScanResponse[], responses: ConfigMessageScanResponse[],
// Just to be safe that we will never use data from the context parameter // Just to be safe that we will never use data from the context parameter
{ api, logger }: Omit<typeof import('src/context'), 'config'>, { api, logger }: Omit<typeof import('src/context'), 'config'>,
ocrMode = false, flags: { onlyImageTriggers?: boolean; skipApiRequest?: boolean } = {}
): Promise<ConfigMessageScanResponse & { label?: string }> => { ): Promise<
let responseConfig: Awaited<ReturnType<typeof getResponseFromText>> = { Omit<ConfigMessageScanResponse, 'triggers'> & { label?: string; triggers?: ConfigMessageScanResponse['triggers'] }
triggers: {}, > => {
type ResponseConfig = Awaited<ReturnType<typeof getResponseFromText>>
let responseConfig: Omit<ResponseConfig, 'triggers'> & { triggers?: ResponseConfig['triggers'] } = {
triggers: undefined,
response: null, response: null,
} }
@@ -27,9 +30,8 @@ export const getResponseFromText = async (
const { const {
triggers: { text: textTriggers, image: imageTriggers }, triggers: { text: textTriggers, image: imageTriggers },
} = trigger } = trigger
if (responseConfig) break
if (ocrMode) { if (flags.onlyImageTriggers) {
if (imageTriggers) if (imageTriggers)
for (const regex of imageTriggers) for (const regex of imageTriggers)
if (regex.test(content)) { if (regex.test(content)) {
@@ -55,36 +57,37 @@ export const getResponseFromText = async (
} }
// If none of the regexes match, we can search for labels immediately // If none of the regexes match, we can search for labels immediately
if (!responseConfig && !ocrMode) { if (!responseConfig.triggers && !flags.onlyImageTriggers && !flags.skipApiRequest) {
logger.debug('No match from before regexes, doing NLP') logger.debug('No match from before regexes, doing NLP')
const scan = await api.client.parseText(content) const scan = await api.client.parseText(content)
if (scan.labels.length) { if (scan.labels.length) {
const matchedLabel = scan.labels[0]! const matchedLabel = scan.labels[0]!
logger.debug(`Message matched label with confidence: ${matchedLabel.name}, ${matchedLabel.confidence}`) logger.debug(`Message matched label with confidence: ${matchedLabel.name}, ${matchedLabel.confidence}`)
let triggerConfig: ConfigMessageScanResponseLabelConfig | undefined let trigger: ConfigMessageScanResponseLabelConfig | undefined
const labelConfig = responses.find(x => { const response = responses.find(x => {
const config = x.triggers.text!.find( const config = x.triggers.text!.find(
(x): x is ConfigMessageScanResponseLabelConfig => 'label' in x && x.label === matchedLabel.name, (x): x is ConfigMessageScanResponseLabelConfig => 'label' in x && x.label === matchedLabel.name,
) )
if (config) triggerConfig = config if (config) trigger = config
return config return config
}) })
if (!labelConfig) { if (!response) {
logger.warn(`No label config found for label ${matchedLabel.name}`) logger.warn(`No response config found for label ${matchedLabel.name}`)
// This returns the default value set in line 17, which means no response matched
return responseConfig return responseConfig
} }
if (matchedLabel.confidence >= triggerConfig!.threshold) { if (matchedLabel.confidence >= trigger!.threshold) {
logger.debug('Label confidence is enough') logger.debug('Label confidence is enough')
responseConfig = labelConfig responseConfig = { ...responseConfig, ...response, label: trigger!.label }
} }
} }
} }
// If we still don't have a response config, we can match all regexes after the initial label trigger // If we still don't have a response config, we can match all regexes after the initial label trigger
if (!responseConfig) { if (!responseConfig.triggers && flags.onlyImageTriggers) {
logger.debug('No match from NLP, doing after regexes') logger.debug('No match from NLP, doing after regexes')
for (let i = 0; i < responses.length; i++) { for (let i = 0; i < responses.length; i++) {
const { const {
@@ -92,8 +95,8 @@ export const getResponseFromText = async (
} = responses[i]! } = responses[i]!
const firstLabelIndex = firstLabelIndexes[i] ?? -1 const firstLabelIndex = firstLabelIndexes[i] ?? -1
for (let i = firstLabelIndex + 1; i < textTriggers!.length; i++) { for (let j = firstLabelIndex + 1; j < textTriggers!.length; j++) {
const trigger = textTriggers![i]! const trigger = textTriggers![j]!
if (trigger instanceof RegExp) { if (trigger instanceof RegExp) {
if (trigger.test(content)) { if (trigger.test(content)) {
@@ -113,15 +116,24 @@ export const messageMatchesFilter = (message: Message, filter: NonNullable<Confi
if (!filter) return true if (!filter) return true
const memberRoles = new Set(message.member?.roles.cache.keys()) const memberRoles = new Set(message.member?.roles.cache.keys())
const blFilter = filter.blacklist const { blacklist, whitelist } = filter
// If matches blacklist, will return false // If matches only blacklist, will return false
// Any other case, will return true // If matches whitelist but also matches blacklist, will return false
return !( // If matches only whitelist, will return true
blFilter && // If matches neither, will return true
(blFilter.channels?.includes(message.channelId) || return (
blFilter.roles?.some(role => memberRoles.has(role)) || (whitelist
blFilter.users?.includes(message.author.id)) ? whitelist.channels?.includes(message.channelId) ||
whitelist.roles?.some(role => memberRoles.has(role)) ||
whitelist.users?.includes(message.author.id)
: true) &&
!(
blacklist &&
(blacklist.channels?.includes(message.channelId) ||
blacklist.roles?.some(role => memberRoles.has(role)) ||
blacklist.users?.includes(message.author.id))
)
) )
} }
@@ -149,7 +161,7 @@ export const handleUserResponseCorrection = async (
await reply.edit({ await reply.edit({
...correctLabelResponse.response, ...correctLabelResponse.response,
embeds: correctLabelResponse.response.embeds?.map(it => createMessageScanResponseEmbed(it, 'nlp')), embeds: correctLabelResponse.response.embeds?.map(createMessageScanResponseEmbed),
}) })
} }

View File

@@ -1,6 +1,6 @@
import { config, logger } from '$/context' import { config, logger } from '$/context'
import decancer from 'decancer' import decancer from 'decancer'
import type { ChatInputCommandInteraction, EmbedBuilder, Guild, GuildMember, Message, User } from 'discord.js' import type { CommandInteraction, EmbedBuilder, Guild, GuildMember, Message, User } from 'discord.js'
import { applyReferenceToModerationActionEmbed, createModerationActionEmbed } from './embeds' import { applyReferenceToModerationActionEmbed, createModerationActionEmbed } from './embeds'
const PresetLogAction = { const PresetLogAction = {
@@ -10,7 +10,7 @@ const PresetLogAction = {
export const sendPresetReplyAndLogs = ( export const sendPresetReplyAndLogs = (
action: keyof typeof PresetLogAction, action: keyof typeof PresetLogAction,
interaction: ChatInputCommandInteraction | Message, interaction: CommandInteraction | Message,
executor: GuildMember, executor: GuildMember,
user: User, user: User,
preset: string, preset: string,
@@ -24,7 +24,7 @@ export const sendPresetReplyAndLogs = (
) )
export const sendModerationReplyAndLogs = async ( export const sendModerationReplyAndLogs = async (
interaction: ChatInputCommandInteraction | Message, interaction: CommandInteraction | Message,
embed: EmbedBuilder, embed: EmbedBuilder,
) => { ) => {
const reply = await interaction.reply({ embeds: [embed] }).then(it => it.fetch()) const reply = await interaction.reply({ embeds: [embed] }).then(it => it.fetch())
@@ -38,7 +38,7 @@ export const getLogChannel = async (guild: Guild) => {
try { try {
const channel = await guild.channels.fetch(logConfig.thread ?? logConfig.channel) const channel = await guild.channels.fetch(logConfig.thread ?? logConfig.channel)
if (!channel || !channel.isTextBased()) if (!channel?.isTextBased())
return void logger.warn('The moderation log channel does not exist, skipping logging') return void logger.warn('The moderation log channel does not exist, skipping logging')
return channel return channel
@@ -54,9 +54,9 @@ export const cureNickname = async (member: GuildMember) => {
const name = member.displayName const name = member.displayName
let cured = decancer(name) let cured = decancer(name)
.toString() .toString()
.replace(/[^a-zA-Z0-9]/g, '') .replace(new RegExp(config.moderation?.cure?.removeCharactersRegex ?? '[^a-zA-Z0-9 \\-_]', 'g'), '')
if (cured.length < 3 || !/^[a-zA-Z]/.test(cured)) if (cured.length < (config?.moderation?.cure?.minimumNameLength ?? 3))
cured = cured =
member.user.username.length >= 3 member.user.username.length >= 3
? member.user.username ? member.user.username

View File

@@ -1,5 +1,5 @@
import { GuildMember, type User } from 'discord.js' import { GuildMember, type User } from 'discord.js'
import config from '../../../config' import { config } from '../../context'
export const isAdmin = (userOrMember: User | GuildMember) => { export const isAdmin = (userOrMember: User | GuildMember) => {
return ( return (

View File

@@ -6,9 +6,9 @@ import { and, eq } from 'drizzle-orm'
// TODO: Fix this type // TODO: Fix this type
type PresetKey = string type PresetKey = string
export const applyRolePreset = async (member: GuildMember, presetName: PresetKey, untilMs: number) => { export const applyRolePreset = async (member: GuildMember, presetName: PresetKey, expires: number) => {
const afterInsert = await applyRolesUsingPreset(presetName, member, true) const { removed, callback } = await applyRolesUsingPreset(presetName, member)
const until = untilMs === Infinity ? null : Math.ceil(untilMs / 1000) const until = expires === Infinity ? null : Math.ceil(expires / 1000)
await database await database
.insert(appliedPresets) .insert(appliedPresets)
@@ -16,39 +16,60 @@ export const applyRolePreset = async (member: GuildMember, presetName: PresetKey
memberId: member.id, memberId: member.id,
guildId: member.guild.id, guildId: member.guild.id,
preset: presetName, preset: presetName,
removedRoles: removed,
until, until,
}) })
.onConflictDoUpdate({ .onConflictDoUpdate({
target: [appliedPresets.memberId, appliedPresets.preset, appliedPresets.guildId], target: [appliedPresets.memberId, appliedPresets.preset, appliedPresets.guildId],
set: { until }, set: { until },
}) })
.then(afterInsert) .then(callback)
} }
export const removeRolePreset = async (member: GuildMember, presetName: PresetKey) => { export const removeRolePreset = async (member: GuildMember, presetName: PresetKey) => {
const afterDelete = await applyRolesUsingPreset(presetName, member, false) const where = and(
eq(appliedPresets.memberId, member.id),
eq(appliedPresets.preset, presetName),
eq(appliedPresets.guildId, member.guild.id),
)
await database const data = await database.query.appliedPresets.findFirst({ where })
.delete(appliedPresets) if (!data) return false
.where(
and( const { callback } = await applyRolesUsingPreset(presetName, member, data.removedRoles)
eq(appliedPresets.memberId, member.id), await database.delete(appliedPresets).where(where).execute().then(callback)
eq(appliedPresets.preset, presetName),
eq(appliedPresets.guildId, member.guild.id), return true
),
)
.execute()
.then(afterDelete)
} }
export const applyRolesUsingPreset = async (presetName: string, member: GuildMember, applying: boolean) => { export const applyRolesUsingPreset = async (
presetName: string,
member: GuildMember,
removePresetGiveRoles?: string[],
) => {
const preset = config.rolePresets?.guilds[member.guild.id]?.[presetName] const preset = config.rolePresets?.guilds[member.guild.id]?.[presetName]
if (!preset) throw new Error(`The preset "${presetName}" does not exist for this server`) if (!preset) throw new Error(`The preset "${presetName}" does not exist for this server`)
const roles = new Set(member.roles.cache.keys()) const roles = new Set(member.roles.cache.keys())
const removed: string[] = []
for (const role of preset.give) roles[applying ? 'add' : 'delete'](role) // If removePresetGiveRoles is not provided, we're applying a preset
for (const role of preset.take) roles[applying ? 'delete' : 'add'](role) if (!removePresetGiveRoles) {
for (const role of preset.give) roles.add(role)
for (const role of preset.take) {
if (roles.has(role)) {
roles.delete(role)
removed.push(role)
}
}
} else {
const guildRoles = await member.guild.roles.fetch()
for (const role of preset.give) roles.delete(role)
for (const role of removePresetGiveRoles) if (guildRoles.has(role)) roles.add(role)
}
return () => member.roles.set(Array.from(roles)) return {
removed,
callback: () => member.roles.set(Array.from(roles)),
}
} }

View File

@@ -1,15 +1,6 @@
export const parseDuration = (duration: string) => { import parse from 'parse-duration'
if (!duration.length) return Number.NaN
const matches = duration.match(/(?:(\d+)d)?(?:(\d+)h)?(?:(\d+)m)?(?:(\d+)s?)?/)!
const [, days, hours, minutes, seconds] = matches.map(Number) export const parseDuration = (duration: string) => parse(duration, 'ms') ?? Number.NaN
return (
(days || 0) * 24 * 60 * 60 * 1000 +
(hours || 0) * 60 * 60 * 1000 +
(minutes || 0) * 60 * 1000 +
(seconds || 0) * 1000
)
}
export const durationToString = (duration: number) => { export const durationToString = (duration: number) => {
if (duration === 0) return '0s' if (duration === 0) return '0s'

BIN
bun.lockb

Binary file not shown.

View File

@@ -32,7 +32,7 @@
"@anolilab/multi-semantic-release": "^1.1.3", "@anolilab/multi-semantic-release": "^1.1.3",
"@biomejs/biome": "^1.8.3", "@biomejs/biome": "^1.8.3",
"@codedependant/semantic-release-docker": "^5.0.3", "@codedependant/semantic-release-docker": "^5.0.3",
"@commitlint/cli": "^19.3.0", "@commitlint/cli": "^19.4.0",
"@commitlint/config-conventional": "^19.2.2", "@commitlint/config-conventional": "^19.2.2",
"@saithodev/semantic-release-backmerge": "^4.0.1", "@saithodev/semantic-release-backmerge": "^4.0.1",
"@semantic-release/changelog": "^6.0.3", "@semantic-release/changelog": "^6.0.3",
@@ -41,10 +41,10 @@
"@tsconfig/strictest": "^2.0.5", "@tsconfig/strictest": "^2.0.5",
"@types/bun": "^1.1.6", "@types/bun": "^1.1.6",
"conventional-changelog-conventionalcommits": "^7.0.2", "conventional-changelog-conventionalcommits": "^7.0.2",
"lefthook": "^1.7.5", "lefthook": "^1.7.14",
"portainer-service-webhook": "https://github.com/newarifrh/portainer-service-webhook#v1", "portainer-service-webhook": "https://github.com/newarifrh/portainer-service-webhook#v1",
"semantic-release": "^24.0.0", "semantic-release": "^24.1.0",
"turbo": "^2.0.9", "turbo": "^2.0.14",
"typescript": "^5.5.4" "typescript": "^5.5.4"
}, },
"trustedDependencies": [ "trustedDependencies": [
@@ -55,6 +55,7 @@
], ],
"patchedDependencies": { "patchedDependencies": {
"@semantic-release/npm@12.0.1": "patches/@semantic-release%2Fnpm@12.0.1.patch", "@semantic-release/npm@12.0.1": "patches/@semantic-release%2Fnpm@12.0.1.patch",
"drizzle-kit@0.22.8": "patches/drizzle-kit@0.22.8.patch" "drizzle-kit@0.22.8": "patches/drizzle-kit@0.22.8.patch",
"decancer@3.2.3": "patches/decancer@3.2.3.patch"
} }
} }

View File

@@ -52,17 +52,20 @@ export default class Client {
// But if we add anything similar, this will cause another race condition // But if we add anything similar, this will cause another race condition
// To fix this, we can try adding a instanced function that would return the currentSequence // To fix this, we can try adding a instanced function that would return the currentSequence
// and it would be updated every time a "heartbeat ack" packet is received // and it would be updated every time a "heartbeat ack" packet is received
return Promise.race([ const packet = await Promise.race([
this.#awaiter.await(ServerOperation.ParsedText, this.ws.currentSequence), this.#awaiter.await(ServerOperation.ParsedText, this.ws.currentSequence),
this.#awaiter.await(ServerOperation.ParseTextFailed, this.ws.timeout + 5000), this.#awaiter.await(ServerOperation.ParseTextFailed, this.ws.currentSequence, this.ws.timeout + 5000),
]) ])
.then(pkt => { .then(pkt => {
if (pkt.op === ServerOperation.ParsedText) return pkt.d if (pkt.op === ServerOperation.ParsedText) return pkt.d
throw new Error('Failed to parse text, the API encountered an error') return null
}) })
.catch(() => { .catch(() => {
throw new Error('Failed to parse text, the API did not respond in time') throw new Error('Failed to parse text, the API did not respond in time')
}) })
if (!packet) throw new Error('Failed to parse text, the API encountered an error')
return packet
} }
/** /**
@@ -82,17 +85,20 @@ export default class Client {
// See line 50 // See line 50
return Promise.race([ const packet = await Promise.race([
this.#awaiter.await(ServerOperation.ParsedImage, this.ws.currentSequence), this.#awaiter.await(ServerOperation.ParsedImage, this.ws.currentSequence),
this.#awaiter.await(ServerOperation.ParseImageFailed, this.ws.timeout + 5000), this.#awaiter.await(ServerOperation.ParseImageFailed, this.ws.currentSequence, this.ws.timeout + 5000),
]) ])
.then(pkt => { .then(pkt => {
if (pkt.op === ServerOperation.ParsedImage) return pkt.d if (pkt.op === ServerOperation.ParsedImage) return pkt.d
throw new Error('Failed to parse image, the API encountered an error') return null
}) })
.catch(() => { .catch(() => {
throw new Error('Failed to parse image, the API did not respond in time') throw new Error('Failed to parse image, the API did not respond in time')
}) })
if (!packet) throw new Error('Failed to parse image, the API encountered an error')
return packet
} }
async trainMessage(text: string, label: string) { async trainMessage(text: string, label: string) {
@@ -107,17 +113,20 @@ export default class Client {
}) })
// See line 50 // See line 50
return Promise.race([ const packet = await Promise.race([
this.#awaiter.await(ServerOperation.TrainedMessage, this.ws.currentSequence), this.#awaiter.await(ServerOperation.TrainedMessage, this.ws.currentSequence),
this.#awaiter.await(ServerOperation.TrainMessageFailed, this.ws.timeout + 5000), this.#awaiter.await(ServerOperation.TrainMessageFailed, this.ws.currentSequence, this.ws.timeout + 5000),
]) ])
.then(pkt => { .then(pkt => {
if (pkt.op === ServerOperation.TrainedMessage) return pkt.d if (pkt.op === ServerOperation.TrainedMessage) return pkt.d
throw new Error('Failed to train message, the API encountered an error') return null
}) })
.catch(() => { .catch(() => {
throw new Error('Failed to train message, the API did not respond in time') throw new Error('Failed to train message, the API did not respond in time')
}) })
if (!packet) throw new Error('Failed to train message, the API encountered an error')
return packet
} }
/** /**

View File

@@ -17,7 +17,7 @@ import { type RawData, WebSocket } from 'ws'
* This is the only relevant class for the time being. But in the future, there may be more classes to handle different protocols of the API. * This is the only relevant class for the time being. But in the future, there may be more classes to handle different protocols of the API.
*/ */
export class ClientWebSocketManager { export class ClientWebSocketManager {
readonly url: string url: string
timeout: number timeout: number
connecting = false connecting = false
@@ -33,6 +33,21 @@ export class ClientWebSocketManager {
this.timeout = options.timeout ?? 10000 this.timeout = options.timeout ?? 10000
} }
/**
* Sets the URL to connect to
*
* **Requires a reconnect to take effect**
*/
async setOptions({ url, timeout }: Partial<ClientWebSocketManagerOptions>, autoReconnect = true) {
if (url) this.url = url
this.timeout = timeout ?? this.timeout
if (autoReconnect) {
this.disconnect(true)
await this.connect()
}
}
/** /**
* Connects to the WebSocket API * Connects to the WebSocket API
* @returns A promise that resolves when the client is ready * @returns A promise that resolves when the client is ready
@@ -77,9 +92,13 @@ export class ClientWebSocketManager {
} catch (e) { } catch (e) {
rj(e) rj(e)
} }
}).finally(() => {
this.connecting = false
}) })
.then(() => {
this.#socket.on('close', (code, reason) => this._handleDisconnect(code, reason.toString()))
})
.finally(() => {
this.connecting = false
})
} }
/** /**
@@ -123,7 +142,7 @@ export class ClientWebSocketManager {
this.currentSequence++ this.currentSequence++
this.#socket.send(serializePacket(packet), err => { this.#socket.send(serializePacket(packet), err => {
throw err if (err) throw err
}) })
} }

View File

@@ -1,9 +1,9 @@
import { import {
url,
type AnySchema, type AnySchema,
type NullSchema, type NullSchema,
type ObjectSchema, type ObjectSchema,
type Output, type Output,
type BooleanSchema,
array, array,
enum_, enum_,
null_, null_,
@@ -11,6 +11,8 @@ import {
parse, parse,
special, special,
string, string,
boolean,
url,
// merge // merge
} from 'valibot' } from 'valibot'
import DisconnectReason from '../constants/DisconnectReason' import DisconnectReason from '../constants/DisconnectReason'
@@ -26,8 +28,7 @@ export const PacketSchema = special<Packet>(input => {
'op' in input && 'op' in input &&
typeof input.op === 'number' && typeof input.op === 'number' &&
input.op in Operation && input.op in Operation &&
'd' in input && 'd' in input
typeof input.d === 'object'
) { ) {
if (input.op in ServerOperation && !('s' in input && typeof input.s === 'number')) return false if (input.op in ServerOperation && !('s' in input && typeof input.s === 'number')) return false
@@ -62,7 +63,7 @@ export const PacketDataSchemas = {
[ServerOperation.Disconnect]: object({ [ServerOperation.Disconnect]: object({
reason: enum_(DisconnectReason), reason: enum_(DisconnectReason),
}), }),
[ServerOperation.TrainedMessage]: null_(), [ServerOperation.TrainedMessage]: boolean(),
[ServerOperation.TrainMessageFailed]: null_(), [ServerOperation.TrainMessageFailed]: null_(),
[ClientOperation.ParseText]: object({ [ClientOperation.ParseText]: object({
@@ -78,7 +79,7 @@ export const PacketDataSchemas = {
} as const satisfies Record< } as const satisfies Record<
Operation, Operation,
// biome-ignore lint/suspicious/noExplicitAny: This is a schema, it's not possible to type it // biome-ignore lint/suspicious/noExplicitAny: This is a schema, it's not possible to type it
ObjectSchema<any> | AnySchema | NullSchema ObjectSchema<any> | AnySchema | NullSchema | BooleanSchema
> >
export type Packet<TOp extends Operation = Operation> = TOp extends ServerOperation export type Packet<TOp extends Operation = Operation> = TOp extends ServerOperation

View File

@@ -0,0 +1,13 @@
diff --git a/src/lib.js b/src/lib.js
index de45d7dbe82975b09eff3742d0718accae2107fc..0575daa03dfabdd5c96928458ff4270cb8f7188a 100644
--- a/src/lib.js
+++ b/src/lib.js
@@ -42,7 +42,7 @@ function isMusl() {
}
function getBinding(name) {
- const path = join(__dirname, '..', `decancer.${name}.node`)
+ const path = join(import.meta.dir, '..', `decancer.${name}.node`)
return require(existsSync(path) ? path : `@vierofernando/decancer-${name}`)
}