Compare commits

..

176 Commits

Author SHA1 Message Date
Jiralite
270d9f1047 chore(core): release @discordjs/core@2.2.2 2025-09-10 18:49:28 +01:00
Jiralite
9ae737708b fix(users): Correct type for editing current guild member (#11098)
* fix(users): `RESTPatchAPICurrentGuildMemberJSONBody`

* fix: imports
2025-09-10 18:45:05 +01:00
Jiralite
e382d60421 build: upgrade discord-api-types to 0.38.24 2025-09-10 18:14:11 +01:00
Jiralite
68aa202cd6 build: upgrade discord-api-types to 0.38.23 2025-09-10 11:34:34 +01:00
VAKiliner
d8ad181c19 fix: Ensure discriminator detection respects webhooks too (#11062)
* Replace discriminator === '0' to Number(discriminator)

* Fix

* Replacing !Number() to ['0', '0000'].includes

* chore: fmt

* perf: no array

---------

Co-authored-by: almeidx <github@almeidx.dev>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-09-05 13:46:27 +01:00
Jiralite
0dff969e16 refactor(ActionsManager): Register actions without using class name (#11080)
* refactor: register actions without using class name

* fix: this is `module.exports`
2025-09-02 10:28:15 +01:00
Jiralite
79d999e4c1 feat: Guest invites (#11079)
feat(Invite): add `flags`
2025-09-02 09:21:11 +01:00
Amgelo563
215f8dc5e0 fix: Do not omit falsy default values (#10755)
* fix(docs): fix default falsy values being omitted

* fix(docs): swap defaultValue check to avoid negated condition

* fix: fix pr by removing everything it added and committing something entirely different

---------

Co-authored-by: almeidx <github@almeidx.dev>
2025-09-02 01:11:24 +01:00
Jiralite
b6089e585e build: upgrade discord-api-types to 0.38.22 2025-09-02 00:53:31 +01:00
Jiralite
fe025c0a9f docs(GuildEditOptions): deprecate owner property 2025-08-29 11:01:16 +01:00
Almeida
4a8aeb6aee feat: polls overhaul (#11058)
* feat: polls overhaul (#10328)

* feat(Managers): add PollAnswerVoterManager

* feat(Partials): make Polls partial-safe

* types: add typings

* chore: add tests

* fix: use fetch method in manager instead

* chore: add tests for manager

* feat: add partial support to poll actions

* style: formatting

* fix: change all .users references to .voters

* refactor: add additional logic for partials

* fix: actually add the partials

* fix: fixed issue where event does not emit on first event

* fix: align property type with DAPI documentation

* fix: resolve additional bugs with partials

* typings: update typings to reflect property type change

* fix: tests

* fix: adjust tests

* refactor: combine partials logic into one statement

* docs: mark getter as readonly

* refactor: apply suggestions

Co-authored-by: Almeida <github@almeidx.dev>

* refactor(Actions): apply suggestions

* refactor(PollAnswerVoterManager): apply suggestions

* refactor(Message): check for existing poll before creating a poll

* refactor(Polls): apply suggestions

* revert(types): remove unused method from Poll class

* refactor(Actions): consolidate poll creation logic into action class

* refactor(PollAnswerVoterManager): set default for fetch parameter

* refactor(Message): apply suggestion

* fix: remove partial setter

* refactor(Polls): apply suggestions

* types: apply suggestions

* refactor: remove clones

* docs: spacing

* refactor: move setters from constructor to _patch

* types: adjust partials for poll classes

* test: add more tests for polls

* refactor: move updates around, more correct partial types

* fix: handle more cases

* refactor: requested changes

* fix: missing imports

* fix: update imports

* fix: require file extensions

---------

Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>

* fix(Poll): ensure `this.answers` is set before we reference it (#10809)

* Ensure 	his.answers is set sooner if it's null during a patch

* Move data.answers block up as well to ensure the patched answers are set

* Ensure collection is set in constructor instead

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>

* fix(PollAnswer): only define _emoji property once (#10811)

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>

---------

Co-authored-by: Kevin <uhkevinmc@eedo.app>
Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
Co-authored-by: Jacob Morrison <jake.morrison24@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-08-29 10:58:04 +01:00
Jiralite
3dd57c2eaf docs: Deprecate API related to guild ownership (#11054)
* docs: deprecate API usage for guild ownership

* docs: remove pointless example
2025-08-29 10:53:34 +01:00
Jiralite
740da4ce5e docs: deprecate setting owner 2025-08-29 10:51:13 +01:00
Jiralite
6fb0b1cef6 docs(guild): deprecate API related to guild ownership 2025-08-22 12:48:29 +01:00
Jiralite
ac6ff15b7d fix(guild): Creating a template actually creates a template (#11030)
feat(guild): add creating a template
2025-08-22 12:41:53 +01:00
Jiralite
a294b47db0 chore(discord.js): release discord.js@14.22.1 2025-08-22 12:05:56 +01:00
Souji
ecef7bdf22 fix(GuildChannel): account for everyone base permissions (#11053)
When calculating permissions after overwrites, the base permission of the at-everyone role need to be accounted for.
Role#permissions is not sufficient, as it only describes base permissions of the role itself.

fixes #11052
2025-08-22 12:04:35 +01:00
Jiralite
40578393c3 chore: add @discordjs/collection in api-extractor.json 2025-08-22 08:57:02 +01:00
Vlad Frangu
86ecb37c9e chore(discord.js): correct changelog and version 2025-08-21 00:58:23 +03:00
Vlad Frangu
311e826b12 chore(discord.js): release discord.js@15.0.0 2025-08-21 00:52:53 +03:00
Vlad Frangu
dceac0089d chore(core): release @discordjs/core@2.2.1 2025-08-21 00:48:29 +03:00
Vlad Frangu
a2f7d3ad54 chore(rest): release @discordjs/rest@2.6.0 2025-08-21 00:43:53 +03:00
Jiralite
b532df61ed fix: Remove trailing color references (#11007)
fix: role colour fixes
2025-07-20 18:01:45 +01:00
Jiralite
d60e0bf30b types(Invite): Approximate fields should be nullable (#10997)
types: nullable fields
2025-07-16 23:41:57 +01:00
Danial Raza
baa08b8fbb feat: support user guilds (#10995) 2025-07-16 22:27:54 +02:00
Jiralite
f469f74aca feat(MessageManager): New pinned messages routes (#10993)
feat(MessageManager)!: New pinned messages routes (#10989)

BREAKING CHANGE: `fetchPinned()` has been renamed to `fetchPins()`, which is a paginated endpoint to fetch pinned messages.
2025-07-16 11:33:16 +01:00
Jiralite
90d3b28268 fix(Emoji): remove incorrect nullables, add ApplicationEmoji#available (#10990)
fix: remove incorrect nullables, add `ApplicationEmoji#available`

Co-authored-by: Amgelo563 <61554601+Amgelo563@users.noreply.github.com>
2025-07-15 19:52:47 +01:00
Jiralite
a271e9b51e build: Upgrade discord-api-types (#10991)
build: Upgrade dependencies
2025-07-15 01:30:58 +01:00
Danial Raza
8ac0e1e5d6 feat(User): add collectibles (#10939)
* feat(User): add `collectibles`

* docs: nullable collectibles and nameplate data

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

---------

Co-Authored-By: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-07-15 00:41:57 +01:00
Jiralite
9d6fdf8979 feat: Role gradient colours (#10986)
* feat: backport role gradient colours

* chore: add deprecations
2025-07-14 18:34:52 +01:00
Jiralite
cafe58b3bd feat: Support animated WebP (#10987)
* feat: Support animated WebP (#10911)

* feat: support animated WebP

* refactor: change the rest

* fix: remove redundant code

* fix(CDN): Export `MakeURLOptions`
2025-07-14 18:34:27 +01:00
Jiralite
7eca844f6d chore: remove unused directive 2025-07-14 10:58:55 +01:00
Naiyar
63f5261f4c fix(GuildChannelManager): properly resolve avatar for createWebhook (#10772)
fix(GuildChannelManager): properly resolve avatr for createWebhook
2025-07-14 02:26:55 +01:00
Almeida
5be774db64 docs: remove hardcoded locale from links (#10794)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-07-14 02:24:17 +01:00
Almeida
b36b751bde fix(Message): forwarded messages are not crosspostable (#10821)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-07-14 02:19:03 +01:00
Danial Raza
500712d5ea types(ModalSubmitFields): fix fields type (#10816)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-07-14 02:17:36 +01:00
Danial Raza
040c66ae15 docs(ApplicationCommand): incorrect method in example (#10837) 2025-07-14 02:10:20 +01:00
Jiralite
82378fc2e8 refactor: Deprecate ready event in favor of clientReady (#10969)
* refactor: deprecate ready event

* refactor: tweak message

Co-Authored-By: Vlad Frangu <me@vladfrangu.dev>

* Update packages/discord.js/src/client/websocket/WebSocketManager.js

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>

* chore: disable max-len

---------

Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
2025-07-13 21:32:22 +01:00
Jiralite
d4f742e99e refactor(ThreadChannel): Remove trimming of name (#10984)
refactor(ThreadChannel): no need to trim name

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-07-13 21:16:44 +01:00
Jiralite
51ceb203fa fix(DirectoryChannel): Export class (#10985)
fix: export directory channels
2025-07-13 21:07:09 +01:00
Qjuh
1404e32849 fix(Events): WebhooksUpdate enum value (#10970)
fix: change Events.WebhooksUpdate value
2025-07-11 19:39:12 +01:00
Jiralite
9fc3e5ea72 fix: Adjust reason in methods options (#10977)
fix: Adjust `reason` in methods options (#10976)

* fix(channel): allow reason in editing

* fix(channel): allow reason in `delete()`

* fix(channel): allow reason in creating threads

* chore: run format

* fix(guild): remove incorrect `reason` option

---------

Co-authored-by: Danial Raza <danialrazafb@gmail.com>
2025-07-11 10:00:58 +01:00
TÆMBØ
19e74b1533 types(InteractionCallbackResponse): add missing InGuild generic (#10963) 2025-07-03 00:21:13 +02:00
Vlad Frangu
de22a10038 chore(discord.js): release discord.js@14.21.0 2025-06-26 01:33:02 +03:00
Vlad Frangu
8ab30cdefa chore(core): release @discordjs/core@2.2.0 2025-06-26 01:26:48 +03:00
Danial Raza
c2a43b685e types(ClientEventTypes): add missing guildSoundboardSoundsUpdate (#10928) 2025-06-25 16:22:07 +01:00
Jiralite
507b696792 fix(ClientUser): Remove token assignment (#10953)
fix(ClientUser): remove token assignment
2025-06-25 16:15:35 +01:00
Jiralite
15f7571243 feat(GuildMember): add avatarDecorationData (#10942)
Co-Authored-By: Danial Raza <danialrazafb@gmail.com>
2025-06-25 12:31:43 +01:00
Almeida
3fa429c7df feat(ClientApplication): add approximateUserAuthorizationCount (#10933)
Co-authored-by: Danial Raza <danialrazafb@gmail.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-06-25 12:22:36 +01:00
Jiralite
7713627fd1 feat(webhook): Support with_components (#10945)
feat: support `with_components`
2025-06-22 10:48:47 +01:00
Vlad Frangu
6a5c0fb32d chore(discord.js): release discord.js@14.20.0 2025-06-16 15:41:03 +03:00
Vlad Frangu
eb5acd1e30 chore(discord.js): bump ws 2025-06-16 15:39:20 +03:00
Vlad Frangu
127021d5ab chore(core): release @discordjs/core@2.1.1 2025-06-16 15:30:25 +03:00
Vlad Frangu
0943bc2efb chore(ws): release @discordjs/ws@2.0.3 2025-06-16 15:29:02 +03:00
Vlad Frangu
a1c83c17d6 chore(rest): release @discordjs/rest@2.5.1 2025-06-16 14:55:15 +03:00
Qjuh
c0eae344c2 feat: backport entrypoint command (#10908) 2025-05-27 21:50:26 +02:00
Qjuh
f2f757ce52 fix: use resolvePartialEmoji on MessagePayload#options#components (#10910)
fix: use resolvePartialEmoji on MessagePayload#components again
2025-05-25 13:35:28 +02:00
Jiralite
65cfa3ffd3 build: Update Undici to 6.21.3 (#10906)
build: update undici
2025-05-20 11:52:49 +01:00
Jiralite
ee2eb7349f fix(ChannelManager): Remove threads from cache upon deletion (#10883)
* fix(ChannelManager): remove threads from cache upon deletion

* refactor: loop over thread ids

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-05-09 14:09:35 +01:00
Jiralite
2d19163d76 perf(Components): Hash table (#10893)
perf(Components): hash table
2025-05-09 08:43:04 +01:00
Jiralite
9bca4af5fd fix(PartialGroupDMChannel): Prevent undefined values (#10889)
fix(PartialGroupDMChannel): prevent `undefined` values

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-05-07 19:09:26 +01:00
Almeida
fe5e344adc build: exclude type tests from pack (#10886)
* build: exclude type tests from pack

* fix: requested changes

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-05-07 17:36:47 +01:00
Jiralite
c8f6066d6a refactor(Client): Remove with_expiration query parameter (#10800)
refactor(Client): remove `with_expiration`

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-05-06 15:25:32 +01:00
Almeida
7e21a9474e feat(BaseInteraction): add attachmentSizeLimit (#10830)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-05-05 03:54:12 +01:00
Jiralite
d0a535ea6a fix(guild): fix incorrectly-detected deprecated overload 2025-05-04 14:19:24 +01:00
Vlad Frangu
8124fc68be chore(discord.js): release discord.js@14.19.3 2025-05-03 01:08:35 +03:00
Vlad Frangu
dbd5354056 chore: bump builders 2025-05-03 01:07:23 +03:00
Qjuh
2ebb5cbd53 fix: regression in allowedMentions when replying (#10866)
* fix: regression in allowedMentions

* fix: lint

* fix: jsdoc

* tests: added a manual regression test

* fix: lint

* fix: tests

* fix: tests

* fix: typo

* fix: typings test

* chore: bumo zlib-sync to not crash on mac
2025-05-02 12:48:57 +02:00
Vlad Frangu
096cd92b87 chore(discord.js): release discord.js@14.19.2 2025-04-28 23:44:30 +03:00
Qjuh
37ef57b880 fix(WebSocketManager): always emit shardDisconnect on unresumable close (#10826) 2025-04-28 23:43:50 +03:00
Danial Raza
e3c247e423 types(GuildSoundboardSoundEditOptions): add missing reason (#10863) 2025-04-28 17:20:59 +01:00
Danial Raza
5f3fc170fb fix(SoundboardSound): wrong emoji comparison in equals (#10861) 2025-04-28 08:46:03 +03:00
Qjuh
20fade2a87 fix: allowedMentions, container, media item toJSON() for components v2 (#10852)
* fix: allowedMentions for components v2

* refactor: passing allowed_mentions

* Update packages/discord.js/src/structures/MessagePayload.js

* fix: missing UnfurledMediaItem#toJSON()

* fix: find interactive component in container

* fix: recursive flatMap

* fix: lint

* refactor: top-level function

* fix: jsdoc

* fix: jsdoc
2025-04-27 17:23:12 +00:00
n1ck_pro
e827644b5a fix(Guild): cache soundboard sounds when patching (#10857) 2025-04-27 00:11:40 +01:00
Danial Raza
62815928ab fix(GuildSoundboardSoundManager): value "undefined" is not snowflake (#10854) 2025-04-26 17:06:52 +03:00
Vlad Frangu
7fb6630c02 chore(ci): backport actions updates (#10853) 2025-04-26 17:06:03 +03:00
Vlad Frangu
737b80b5f2 chore(discord.js): release discord.js@14.19.1 2025-04-26 04:17:25 +03:00
Vlad Frangu
481ccd228b fix: add in withComponents to Webhook 2025-04-26 04:16:58 +03:00
Vlad Frangu
a3fff7b8be chore(discord.js): release discord.js@14.19.0 2025-04-26 03:58:29 +03:00
Vlad Frangu
8cdbe23766 fix: set with_components when sending components through webhooks 2025-04-26 03:57:04 +03:00
Vlad Frangu
d920933dc5 fix(GuildAuditLogEntry): fix some incorrect types and runtime logic (#10849)
* fix(GuildAuditLogEntry): fix some incorrect types and runtime logic

* chore: bite me
2025-04-26 00:54:17 +01:00
Danial Raza
2d817df3b5 feat: soundboard missing things (#10850) 2025-04-26 00:49:05 +01:00
Vlad Frangu
1605a2c289 fix: spread out section components next to accessory 2025-04-26 02:37:33 +03:00
Vlad Frangu
464ea2ab30 chore(core): release @discordjs/core@2.1.0 2025-04-26 01:11:36 +03:00
Vlad Frangu
0d1d54a537 chore(ws): release @discordjs/ws@2.0.2 2025-04-26 01:09:37 +03:00
Vlad Frangu
dd8bb397a8 chore(deps): bump discord-api-types round at least 2 2025-04-26 01:08:54 +03:00
Vlad Frangu
61d3d6d4ae chore: bump builders and ws 2025-04-26 00:59:43 +03:00
Vlad Frangu
512b0c67b9 chore(rest): release @discordjs/rest@2.5.0 2025-04-26 00:07:10 +03:00
Vlad Frangu
532c3842bc fix: correctly extend CachedManager in GuildSoundboardSoundManager 2025-04-25 23:52:21 +03:00
Qjuh
edace17a13 feat: components v2 in v14 (#10781)
Co-authored-by: Naiyar <137700126+imnaiyar@users.noreply.github.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
Co-authored-by: Timo <mail@geniustimo.de>
2025-04-25 23:43:09 +03:00
Danial Raza
d3154cf8f1 feat: add soundboard in v14 (#10843) 2025-04-25 23:37:03 +03:00
René
45552faf02 types: make Client.on() compatible with esnext.disposable in TS5.6+ (#10773) 2025-02-24 11:39:22 +02:00
Danial Raza
ebfd52695e fix(MessagePayload): preserve existing flags when editing (#10766)
* fix(MessagePayload): preserve existing flags when editing

* refactor: sync with #10765
2025-02-21 15:21:16 +00:00
Vlad Frangu
595bded8a5 chore(discord.js): release discord.js@14.18.0 2025-02-11 01:05:37 +02:00
Vlad Frangu
c74c632cdb build: bump @discordjs/ws to 1.2.1 2025-02-11 01:04:37 +02:00
Vlad Frangu
fc003050de build: bump @discordjs/builders to 1.10.1 2025-02-11 00:57:15 +02:00
Vlad Frangu
8702978057 chore(rest): release @discordjs/rest@2.4.3 2025-02-11 00:54:31 +02:00
Jiralite
c2b18d6d8b build: bump undici to 6.21.1 2025-02-08 15:39:26 +00:00
Jiralite
519aa3abe8 build: bump discord-api-types to 0.37.119 2025-02-07 21:45:33 +00:00
Naiyar
89c076c89e feat: message forwards (#10733)
* feat: message forwards

* fix: spelling

* feat: add guildId option for forward

* refactor: type

* refactor: do not use ID suffix for resolvables

* Update TextBasedChannel.js

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-02-07 21:42:30 +00:00
Jiralite
f224a07381 build: modify origin/main to origin/v14 2025-02-06 00:01:27 +00:00
Syed Waheed
8e1e1be0c2 fix(Guild): type error with permissionOverwrites (#10527)
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
2025-02-04 20:56:10 +02:00
Vlad Frangu
193a5e9e20 types: fix recurrence rule types (#10694)
* types: fix recurrence rule types

* fix: endAt not endsAt

* types: remove fields that cannot be set by the client

* chore: cleanup JS lands too

* chore: missed you

* chore: bite me

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-02-04 14:06:46 +00:00
ŊʂƓ PRIYANSHU
73c6bc2c36 chore: Add contributors and last commit badges (#10428)
* chore: add new fancy badges

* chore: add util

* style: remove extra space

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-29 15:06:09 +00:00
Naiyar
b7f1ebc334 fix: incorrect relative path (#10734) 2025-01-29 14:51:11 +00:00
Jiralite
92aea94411 style: prettier 2025-01-29 09:48:17 +00:00
Jiralite
41dee5177d feat: Incident Actions (#10727)
* feat: initial commit

* feat: add guild helper

* docs: `guild` is required

* docs(IncidentActions): move to guild

* fix: `incidents_data` is nullable

* fix: method typo

* fix: default to `null`

* fix: use `new Date()`

* docs: note that it is not received over the gateway

* refactor: use transformer

* chore: resolve TODO

* chore: typo

Co-authored-by: Danial Raza <danialrazafb@gmail.com>

* chore: suggestions

Co-authored-by: Almeida <github@almeidx.dev>

* chore: consistency

Co-authored-by: Almeida <github@almeidx.dev>

---------

Co-authored-by: Danial Raza <danialrazafb@gmail.com>
Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-29 09:43:27 +00:00
Jiralite
bbde371324 build: bump discord-api-types to 0.37.118 2025-01-29 09:35:44 +00:00
Jiralite
66b971899a docs: Use link tags to render links on the documentation (#10731)
* docs: use link tags

* docs(DateResolvable): update link

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-29 09:29:59 +00:00
Qjuh
43235d43fe feat(website): type parameters links, builtin doc links, default values (#10515)
* feat(website): links to type parameters, builtin doc links in api.json

* feat(website): show default values for params and props in excerpt

* fix: link in jsdoc

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-01-28 13:32:29 +00:00
Amgelo563
31df3d21cd docs(Message): improve message snapshots description (#10709)
* docs(Message): improve message snapshots description

* docs(Message): remove snapshots single entry callout

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-01-25 19:49:28 +00:00
Almeida
2663d76709 refactor: use throw instead of Promise.reject (#10712)
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Renegade334 <Renegade334@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-24 09:41:17 +00:00
Danial Raza
44a1e85847 types(ThreadOnlyChannel): remove incorrect messages property (#10708)
* types(ThreadOnlyChannel): remove incorrect `messages` property

Co-authored-by: TÆMBØ <TAEMBO@users.noreply.github.com>

* test: t e s t s

* test: revamp tests

---------

Co-authored-by: TÆMBØ <TAEMBO@users.noreply.github.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-01-18 07:45:39 +00:00
Danial Raza
d2e1924fa6 types: add undefined to flags for exactOptionalPropertyTypes (#10707) 2025-01-18 07:44:24 +00:00
Naiyar
68dd260dee types: Allow only ephemeral for defer reply (#10696)
* fix(types): remove unusable flags from InteractionDeferReplyOptions

* fix: include flags in WebhookMessageEditOptions

* chore: update jsdoc

* fix: wrong order

* chore: specify the flag

* chore: extend MessageEditOptions

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-01-18 07:43:13 +00:00
Naiyar
5e66f85f55 feat(PartialGroupDMChannel): add missing properties (#10502)
* fix(PartialGroupDMChannel): add missing ownerId property

* refactor: make ownerID nullable

* feat: add last_message_id & last_pin_timestamp prop

* feat: add component collector methods

* fix: handle null case

Co-authored-by: Vlad Frangu <me@vladfrangu.dev>

---------

Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-14 09:28:10 +00:00
Almeida
46060419a9 refactor: remove data resolver exports (#10701)
refactor!: remove data resolver exports

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-13 10:35:40 +00:00
Digital
7c1b73cc69 fix(PresenceUpdate): correctly add user regardless of their properties (#10672)
* fix(PresenceUpdate): correctly add user regardless of their properties

* refactor(PresenceUpdate): reflect partials

* refactor(PresenceUpdate): prettier

* refactor(PresenceUpdate): add import

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-13 10:34:19 +00:00
Naiyar
95db597fc8 refactor(IntegrationApplication): move common properties to Application (#10627)
* refactor(IntegrationApplication): move common properties to Application

* fix: remove prop from ClientApplication
2025-01-13 10:33:56 +00:00
Almeida
0047a49b73 types: remove createComponent and createComponentBuilder (#10687)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-13 10:31:58 +00:00
Naiyar
32dff01f29 fix(InteractionResponses): mark replied true for followUps (#10688)
fix: mark replied true for followUps

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-01-12 22:29:58 +00:00
Danial Raza
efa50fc3fa feat(Subscription): add renewalSkuIds (#10662) 2025-01-09 19:03:50 +00:00
Qjuh
aa61c20ffd feat(website): include reexported members in docs (#10518)
* feat(website): add re-exported members to docs site

* refactor(scripts): rewrite sourceURL for externals

* feat(website): add external badge

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-09 19:02:28 +00:00
Jiralite
d48136bee1 chore(discord.js): release discord.js@14.17.3 2025-01-08 00:33:54 +00:00
Jiralite
46bf8f0146 fix(Message): Ensure channel is defined for clean content (#10681)
fix(Message): ensure channel is defined for clean content

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-01-04 17:23:55 +00:00
Danial Raza
7280d4e82e fix: use resolve() for PermissionOverwrites (#10686)
* fix: use `resolve()` for `PermissionOverwrites`

* fix: typo

Co-authored-by: René <contact.9a5d6388@renegade334.me.uk>

---------

Co-authored-by: René <contact.9a5d6388@renegade334.me.uk>
2025-01-04 17:09:05 +00:00
Jiralite
bd2914cc98 chore(discord.js): release discord.js@14.17.2 2025-01-02 00:27:09 +00:00
Jiralite
77804cfd55 fix(InteractionResponses): check correct property for deprecation
Resolves #10676.
2025-01-02 00:07:51 +00:00
Vlad Frangu
8fea3ed978 chore(discord.js): release discord.js@14.17.1 2025-01-02 01:48:25 +02:00
Vlad Frangu
05c63cd9a1 chore(rest): release @discordjs/rest@2.4.2 2025-01-02 01:45:37 +02:00
Jiralite
8d69b24b5c fix: correct guild member banner URL 2025-01-01 23:44:32 +00:00
Vlad Frangu
9baee4b2ce chore(discord.js): release discord.js@14.17.0 2025-01-02 00:29:07 +02:00
Vlad Frangu
c986a99104 chore(core): release @discordjs/core@2.0.1 2025-01-02 00:25:35 +02:00
Vlad Frangu
2b9e4cf9d0 chore(ws): release @discordjs/ws@2.0.1 2025-01-02 00:22:09 +02:00
Vlad Frangu
1af2f4ed0e chore: point ws to ^1.2.0 2025-01-02 00:09:25 +02:00
Vlad Frangu
3fbfe9f1ae chore: deps update 2025-01-01 23:43:16 +02:00
Vlad Frangu
b901ff7c4c chore: bump builders, formatters and unpin ws 2025-01-01 23:38:43 +02:00
Vlad Frangu
5f8915f6d1 chore(rest): release @discordjs/rest@2.4.1 2025-01-01 23:38:38 +02:00
Jiralite
ff42d7af72 fix(InteractionResponses): do not use in if a string is passed 2024-12-24 18:20:02 +00:00
Jiralite
0fdbabea98 build: bump discord-api-types to 0.37.114 2024-12-24 12:06:51 +00:00
Jiralite
e9944b3d2d build: bump discord-api-types to 0.37.113 2024-12-22 20:58:53 +00:00
Jiralite
2b9833cd36 Revert "feat(ClientApplication): add webhook events (#10588)"
This reverts commit 7b2a2e3a15.
2024-12-19 00:14:41 +00:00
Naiyar
7b2a2e3a15 feat(ClientApplication): add webhook events (#10588)
* feat(ClientApplication): add webhook events

* refactor: update enum names and add external types

* docs(APITypes): reorder

* chore: requested changes

* chore: requested changes

* docs: remove redundancy

* Update ClientApplication.js

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Almeida <github@almeidx.dev>
2024-12-19 00:12:47 +00:00
Naiyar
6087088579 fix: use Message#interactionMetadata (#10654) 2024-12-19 00:10:31 +00:00
Jiralite
622acbcbf0 feat(InteractionResponses): support with_response query parameter (#10636)
feat(InteractionResponses): support with_response query parameter

Co-authored-by: Ryan Munro <monbrey@gmail.com>
2024-12-18 18:58:22 +00:00
Danial Raza
b2754d4a0e fix(InteractionResponses): properly resolve message flags (#10661) 2024-12-18 15:02:35 +00:00
Jiralite
53cbb0e36d test: Remove unused test (#10638)
test: remove unused test

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-12-05 23:23:02 +00:00
Jiralite
7ce6f2fc8a refactor(FetchApplicationCommandOptions): Use Locale over LocaleString (#10625)
refactor(FetchApplicationCommandOptions): prefer `Locale`
2024-12-05 22:05:23 +00:00
Vlad Frangu
76042f0538 docs: correct discord-api-types URLs (#10622)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-12-05 22:03:31 +00:00
Danial Raza
dedaa5d657 refactor: use cache.get() for snowflakes, resolve() otherwise (#10626)
* refactor: use `cache.get()` for snowflakes, `resolve()` otherwise

* fix: requested changes

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

* chore: remove unnecessary `?? null`

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-12-05 22:01:13 +00:00
Jiralite
ed00a10e1f build: Bump discord-api-types to 0.37.109 (#10619)
build: bump discord-api-types
2024-12-05 21:48:14 +00:00
Naiyar
ae1deac2bf feat(ClientApplication): add webhook events (#10588)
* feat(ClientApplication): add webhook events

* refactor: update enum names and add external types

* docs(APITypes): reorder

* chore: requested changes

* chore: requested changes

* docs: remove redundancy

* Update ClientApplication.js

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Almeida <github@almeidx.dev>
2024-12-05 20:30:44 +00:00
Jiralite
a367e2c8c9 feat(EntitlementManager): Support get entitlement (#10606)
* feat: support get entitlement

* docs: add return type

Co-authored-by: Danial Raza <danialrazafb@gmail.com>

* fix: property typo

Co-authored-by: Almeida <github@almeidx.dev>

* fix: property typo

Co-authored-by: Almeida <github@almeidx.dev>

---------

Co-authored-by: Danial Raza <danialrazafb@gmail.com>
Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-12-05 15:58:19 +00:00
Jiralite
7678f1176a fix(ThreadChannel): Make ownerId always present (#10618)
fix: thread owner id is always present

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-12-05 15:57:06 +00:00
Danial Raza
4cca33d9b0 feat: add subscriptions (#10541)
* feat: add subscriptions

* types: fix fetch options types

* fix: correct properties in patch method

* chore: requested changes

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>

* fix: correct export syntax

* chore(Entitlement): mark `ends_at` as nullable`

* types(FetchSubscriptionOptions): add missing `cache` option

* Revert "types(FetchSubscriptionOptions): add missing `cache` option"

This reverts commit ba472bdc599e1860754e59fce4806610f06ac682.

* chore(Entitlement): mark `startsTimestamp` as nullable

* fix: requested changes

* docs(SubscriptionManager): correct return type

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-12-02 08:33:52 +00:00
Jiralite
388783d7dd docs: Typos (#10628)
chore: typos

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-12-02 08:31:15 +00:00
Jiralite
bda31284bf feat: Emit reaction type on gateway events (#10598)
feat: emit reaction type

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-11-18 12:16:35 +00:00
Jiralite
76968b4bc1 fix(MessageReaction): Address undefined burst properties (#10597)
* fix(MessageReaction): `undefined` burst properties

* refactor: simpler burst colour check

Co-authored-by: Almeida <github@almeidx.dev>

---------

Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-11-18 11:47:51 +00:00
Jiralite
34343c6afa feat: Voice Channel Effect Send (#10318)
* feat: Voice Channel Send Effects (#9288)

* feat: add soundboard fields

* chore: address TODO

* docs: volume is a closed interval

* types: use `GatewayVoiceChannelEffectSendDispatchData`

* refactor: prefer getting from cache

* fix: correctly access cache

Co-authored-by: Danial Raza <danialrazafb@gmail.com>

---------

Co-authored-by: Danial Raza <danialrazafb@gmail.com>
2024-11-15 13:51:07 +00:00
Jiralite
56c9396b71 fix(ThreadChannel): Address parameter type on fetchOwner() (#10592)
fix(ThreadChannel): address parameter type on fetchOwner()
2024-11-13 16:51:16 +00:00
Naiyar
21c283f964 fix(InteractionResponses): throw error on deleting response of unacknowledged interaction (#10587)
fix: error on deleting response of non-acknowledged interaction

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-11-11 15:55:19 +00:00
Danial Raza
13471fa1b7 types: add missing Caches managers (#10540)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-11-05 09:36:51 +00:00
Jiralite
b1ded63e42 feat(GuildMember): Banners (#10384)
* feat: initial support for guild member banners

* feat: serialise in `toJSON()`

* feat: serialise in `toJSON()`

* docs: lowercase i

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-11-05 09:35:59 +00:00
Souji
565fc0192a docs: add note about idempotence to role add/remove routes (#10586)
* chore(docs): Add note about idempotence to role add/remove routes

* chore: remove trailing spaces
2024-11-05 09:29:46 +00:00
Jiralite
33533b7284 refactor: remove extra traversing (#10580)
* refactor: remove extra traversion

* refactor(GuildScheduledEventManager): address fetch
2024-10-25 11:13:49 +01:00
Jiralite
be38f57926 refactor(InteractionResponses): Deprecate ephemeral response option (#10574)
* refactor(InteractionResponses): deprecate `ephemeral` response option

* refactor: add runtime deprecations

* docs: fix reference

Co-authored-by: Almeida <github@almeidx.dev>

* types: add `MessageFlagsResolvable`

---------

Co-authored-by: Almeida <github@almeidx.dev>
2024-10-25 09:15:54 +01:00
almeidx
f79ba52c7a docs(Client): fix incorrect managers descriptions
Co-authored-by: Luna <84203950+Wolvinny@users.noreply.github.com>
2024-10-12 19:30:51 +01:00
Jiralite
72e0c99454 refactor: Deprecate reason parameter on adding and removing thread members (#10551)
* refactor: deprecate `reason` on thread member add and remove

* chore: address TSLint errors

* refactor: use function
2024-10-12 00:57:14 +01:00
Jiralite
3d06c9d872 refactor: Deprecate fetching user flags (#10550)
* refactor: deprecate fetching user flags

* docs: fix reference

Co-authored-by: Almeida <github@almeidx.dev>

* refactor: use function

* refactor: name approach

---------

Co-authored-by: Almeida <github@almeidx.dev>
2024-10-12 00:52:09 +01:00
Eejit
831aafa733 fix(GuildScheduledEvent): handle null recurrence_rule (#10543)
* fix(GuildScheduledEvent): handle null recurrence_rule

* refactor: consistency

* feat: implement suggested logic change

* fix: correct data.recurrence_rule check

---------

Co-authored-by: Almeida <github@almeidx.dev>
2024-10-11 09:03:48 +01:00
Amgelo563
5faf074c14 types: remove newMessage partial on messageUpdate event typing (#10526)
* types: remove newMessage partial on messageUpdate event typing

* types: omit partial group DM for newMessage on messageUpdate

* types: omit partial group DM for oldMessage on messageUpdate

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-10-09 12:47:29 +01:00
Superchupu
297e959f48 docs(discord.js): remove utf-8-validate (#10531) 2024-10-09 12:46:05 +01:00
Moebits
1fc87a9698 feat: Add ApplicationEmoji to EmojiResolvable and MessageReaction#emoji (#10477)
* types: add ApplicationEmoji to EmojiResolvable

* typings: add ApplicationEmoji to MessageReaction#emoji

* removed ApplicationEmoji from MessageReaction

* update BaseGuildEmojiManager

* chore: lint error

* feat: add ApplicationEmoji to MessageReaction#emoji getter

* refactor: check application emojis first

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-10-09 12:42:48 +01:00
Qjuh
366f7174d0 chore: unpin discord-api-types (#10524)
* chore: unpin discord-api-types

* chore: bump discord-api-types
2024-10-01 19:00:33 +01:00
Almeida
97c3237a70 feat: recurring scheduled events (#10447)
* feat: recurring scheduled events

* fix: nullable on patch

* docs: remove unnecessary parenthesis

Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>

---------

Co-authored-by: Vlad Frangu <kingdgrizzle@gmail.com>
2024-09-30 11:27:54 +01:00
TÆMBØ
c12217829b feat: message forwarding (#10464)
* feat: message forwarding

* fix: redundant usage

* feat: add additional snapshot fields

* refactor: use collection to store snapshots

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-09-30 11:27:51 +01:00
337 changed files with 15648 additions and 6648 deletions

View File

@@ -2,7 +2,7 @@ version = 1
[merge]
require_automerge_label = false
blocking_labels = ['blocked', 'in review', 'semver:major']
blocking_labels = ['blocked', 'in review']
method = 'squash'
[merge.message]

2
.github/CODEOWNERS vendored
View File

@@ -1,5 +1,5 @@
# Learn how to add code owners here:
# https://help.github.com/en/articles/about-code-owners
# https://help.github.com/articles/about-code-owners
* @iCrawl

View File

@@ -1,4 +1,4 @@
# https://docs.github.com/en/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
# https://docs.github.com/actions/using-workflows/caching-dependencies-to-speed-up-workflows#force-deleting-cache-entries
name: Cleanup caches
on:
pull_request:

View File

@@ -103,7 +103,15 @@ jobs:
if: ${{ env.REF_TYPE == 'tag' && (!inputs.ref || inputs.ref == 'main') }}
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
CF_D1_DOCS_API_KEY: ${{ secrets.CF_D1_DOCS_API_KEY }}
CF_D1_DOCS_ID: ${{ secrets.CF_D1_DOCS_ID }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
CF_R2_DOCS_URL: ${{ secrets.CF_R2_DOCS_URL }}
CF_R2_DOCS_ACCESS_KEY_ID: ${{ secrets.CF_R2_DOCS_ACCESS_KEY_ID }}
CF_R2_DOCS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_DOCS_SECRET_ACCESS_KEY }}
CF_R2_DOCS_BUCKET: ${{ secrets.CF_R2_DOCS_BUCKET }}
CF_R2_DOCS_BUCKET_URL: ${{ secrets.CF_R2_DOCS_BUCKET_URL }}
uses: ./packages/actions/src/uploadDocumentation
with:
package: ${{ steps.extract-tag.outputs.package }}
@@ -113,7 +121,15 @@ jobs:
if: ${{ env.REF_TYPE == 'tag' && inputs.ref && inputs.ref != 'main' }}
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
CF_D1_DOCS_API_KEY: ${{ secrets.CF_D1_DOCS_API_KEY }}
CF_D1_DOCS_ID: ${{ secrets.CF_D1_DOCS_ID }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
CF_R2_DOCS_URL: ${{ secrets.CF_R2_DOCS_URL }}
CF_R2_DOCS_ACCESS_KEY_ID: ${{ secrets.CF_R2_DOCS_ACCESS_KEY_ID }}
CF_R2_DOCS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_DOCS_SECRET_ACCESS_KEY }}
CF_R2_DOCS_BUCKET: ${{ secrets.CF_R2_DOCS_BUCKET }}
CF_R2_DOCS_BUCKET_URL: ${{ secrets.CF_R2_DOCS_BUCKET_URL }}
uses: ./main/packages/actions/src/uploadDocumentation
with:
package: ${{ steps.extract-tag.outputs.package }}
@@ -123,6 +139,10 @@ jobs:
if: ${{ env.REF_TYPE == 'tag' && (!inputs.ref || inputs.ref == 'main') }}
env:
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
CF_R2_DOCS_URL: ${{ secrets.CF_R2_DOCS_URL }}
CF_R2_DOCS_ACCESS_KEY_ID: ${{ secrets.CF_R2_DOCS_ACCESS_KEY_ID }}
CF_R2_DOCS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_DOCS_SECRET_ACCESS_KEY }}
CF_R2_DOCS_BUCKET: ${{ secrets.CF_R2_DOCS_BUCKET }}
uses: ./packages/actions/src/uploadSplitDocumentation
with:
package: ${{ steps.extract-tag.outputs.package }}
@@ -132,6 +152,10 @@ jobs:
if: ${{ env.REF_TYPE == 'tag' && inputs.ref && inputs.ref != 'main' }}
env:
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
CF_R2_DOCS_URL: ${{ secrets.CF_R2_DOCS_URL }}
CF_R2_DOCS_ACCESS_KEY_ID: ${{ secrets.CF_R2_DOCS_ACCESS_KEY_ID }}
CF_R2_DOCS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_DOCS_SECRET_ACCESS_KEY }}
CF_R2_DOCS_BUCKET: ${{ secrets.CF_R2_DOCS_BUCKET }}
uses: ./main/packages/actions/src/uploadSplitDocumentation
with:
package: ${{ steps.extract-tag.outputs.package }}
@@ -155,26 +179,50 @@ jobs:
if: ${{ env.REF_TYPE == 'branch' && (!inputs.ref || inputs.ref == 'main') }}
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
CF_D1_DOCS_API_KEY: ${{ secrets.CF_D1_DOCS_API_KEY }}
CF_D1_DOCS_ID: ${{ secrets.CF_D1_DOCS_ID }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
CF_R2_DOCS_URL: ${{ secrets.CF_R2_DOCS_URL }}
CF_R2_DOCS_ACCESS_KEY_ID: ${{ secrets.CF_R2_DOCS_ACCESS_KEY_ID }}
CF_R2_DOCS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_DOCS_SECRET_ACCESS_KEY }}
CF_R2_DOCS_BUCKET: ${{ secrets.CF_R2_DOCS_BUCKET }}
CF_R2_DOCS_BUCKET_URL: ${{ secrets.CF_R2_DOCS_BUCKET_URL }}
uses: ./packages/actions/src/uploadDocumentation
- name: Upload documentation to database
if: ${{ env.REF_TYPE == 'branch' && inputs.ref && inputs.ref != 'main' }}
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
CF_D1_DOCS_API_KEY: ${{ secrets.CF_D1_DOCS_API_KEY }}
CF_D1_DOCS_ID: ${{ secrets.CF_D1_DOCS_ID }}
CF_ACCOUNT_ID: ${{ secrets.CF_ACCOUNT_ID }}
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
CF_R2_DOCS_URL: ${{ secrets.CF_R2_DOCS_URL }}
CF_R2_DOCS_ACCESS_KEY_ID: ${{ secrets.CF_R2_DOCS_ACCESS_KEY_ID }}
CF_R2_DOCS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_DOCS_SECRET_ACCESS_KEY }}
CF_R2_DOCS_BUCKET: ${{ secrets.CF_R2_DOCS_BUCKET }}
CF_R2_DOCS_BUCKET_URL: ${{ secrets.CF_R2_DOCS_BUCKET_URL }}
uses: ./main/packages/actions/src/uploadDocumentation
- name: Upload split documentation to blob storage
if: ${{ env.REF_TYPE == 'branch' && (!inputs.ref || inputs.ref == 'main') }}
env:
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
CF_R2_DOCS_URL: ${{ secrets.CF_R2_DOCS_URL }}
CF_R2_DOCS_ACCESS_KEY_ID: ${{ secrets.CF_R2_DOCS_ACCESS_KEY_ID }}
CF_R2_DOCS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_DOCS_SECRET_ACCESS_KEY }}
CF_R2_DOCS_BUCKET: ${{ secrets.CF_R2_DOCS_BUCKET }}
uses: ./packages/actions/src/uploadSplitDocumentation
- name: Upload split documentation to blob storage
if: ${{ env.REF_TYPE == 'branch' && inputs.ref && inputs.ref != 'main' }}
env:
BLOB_READ_WRITE_TOKEN: ${{ secrets.BLOB_READ_WRITE_TOKEN }}
CF_R2_DOCS_URL: ${{ secrets.CF_R2_DOCS_URL }}
CF_R2_DOCS_ACCESS_KEY_ID: ${{ secrets.CF_R2_DOCS_ACCESS_KEY_ID }}
CF_R2_DOCS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_DOCS_SECRET_ACCESS_KEY }}
CF_R2_DOCS_BUCKET: ${{ secrets.CF_R2_DOCS_BUCKET }}
uses: ./main/packages/actions/src/uploadSplitDocumentation
- name: Move docs to correct directory

View File

@@ -9,7 +9,9 @@
<a href="https://www.npmjs.com/package/discord.js"><img src="https://img.shields.io/npm/v/discord.js.svg?maxAge=3600" alt="npm version" /></a>
<a href="https://www.npmjs.com/package/discord.js"><img src="https://img.shields.io/npm/dt/discord.js.svg?maxAge=3600" alt="npm downloads" /></a>
<a href="https://github.com/discordjs/discord.js/actions"><img src="https://github.com/discordjs/discord.js/actions/workflows/test.yml/badge.svg" alt="Tests status" /></a>
<a href="https://codecov.io/gh/discordjs/discord.js" ><img src="https://codecov.io/gh/discordjs/discord.js/branch/main/graph/badge.svg?precision=2" alt="Code coverage" /></a>
<a href="https://github.com/discordjs/discord.js/commits/main"><img src="https://img.shields.io/github/last-commit/discordjs/discord.js.svg?logo=github&logoColor=ffffff" alt="Last commit." /></a>
<a href="https://github.com/discordjs/discord.js/graphs/contributors"><img src="https://img.shields.io/github/contributors/discordjs/discord.js.svg?maxAge=3600&logo=github&logoColor=fff&color=00c7be" alt="contributors" /></a>
<a href="https://codecov.io/gh/discordjs/discord.js"><img src="https://codecov.io/gh/discordjs/discord.js/branch/main/graph/badge.svg?precision=2" alt="Code coverage" /></a>
</p>
<p>
<a href="https://vercel.com/?utm_source=discordjs&utm_campaign=oss"><img src="https://raw.githubusercontent.com/discordjs/discord.js/main/.github/powered-by-vercel.svg" alt="Vercel" /></a>

View File

@@ -18,8 +18,9 @@ export async function Badges({ node }: { readonly node: any }) {
const isAbstract = node.isAbstract;
const isReadonly = node.isReadonly;
const isOptional = node.isOptional;
const isExternal = node.isExternal;
const isAny = isDeprecated || isProtected || isStatic || isAbstract || isReadonly || isOptional;
const isAny = isDeprecated || isProtected || isStatic || isAbstract || isReadonly || isOptional || isExternal;
return isAny ? (
<div className="mb-1 flex gap-3">
@@ -33,6 +34,7 @@ export async function Badges({ node }: { readonly node: any }) {
{isAbstract ? <Badge className="bg-cyan-500/20 text-cyan-500">abstract</Badge> : null}
{isReadonly ? <Badge className="bg-purple-500/20 text-purple-500">readonly</Badge> : null}
{isOptional ? <Badge className="bg-cyan-500/20 text-cyan-500">optional</Badge> : null}
{isExternal ? <Badge className="bg-purple-500/20 text-purple-500">external</Badge> : null}
</div>
) : null;
}

View File

@@ -6,19 +6,19 @@
"private": true,
"scripts": {
"build": "turbo run build --concurrency=4",
"build:affected": "turbo run build --filter=...[origin/main] --concurrency=4",
"build:affected": "turbo run build --filter=...[origin/v14] --concurrency=4",
"build:apps": "turbo run build:local --filter=...{apps/*} --concurrency=4",
"build:apps:affected": "turbo run build:local --filter=...{apps/*}[origin/main] --concurrency=4",
"build:apps:affected": "turbo run build:local --filter=...{apps/*}[origin/v14] --concurrency=4",
"test": "turbo run test --concurrency=4",
"test:affected": "turbo run test --filter=...[origin/main] --concurrency=4",
"test:affected": "turbo run test --filter=...[origin/v14] --concurrency=4",
"lint": "turbo run lint --concurrency=4",
"lint:affected": "turbo run lint --filter=...[origin/main] --concurrency=4",
"lint:affected": "turbo run lint --filter=...[origin/v14] --concurrency=4",
"format": "turbo run format --concurrency=4",
"format:affected": "turbo run format --filter=...[origin/main] --concurrency=4",
"format:affected": "turbo run format --filter=...[origin/v14] --concurrency=4",
"fmt": "turbo run format --concurrency=4",
"fmt:affected": "turbo run format --filter=...[origin/main] --concurrency=4",
"fmt:affected": "turbo run format --filter=...[origin/v14] --concurrency=4",
"docs": "turbo run docs --concurrency=4",
"docs:affected": "turbo run docs --filter=...[origin/main] --concurrency=4",
"docs:affected": "turbo run docs --filter=...[origin/v14] --concurrency=4",
"prepare": "is-ci || husky",
"update": "pnpm --recursive update --interactive",
"update:latest": "pnpm --recursive update --interactive --latest",

View File

@@ -41,28 +41,32 @@
"homepage": "https://discord.js.org",
"funding": "https://github.com/discordjs/discord.js?sponsor",
"dependencies": {
"@actions/core": "^1.10.1",
"@actions/core": "^1.11.1",
"@actions/glob": "^0.5.0",
"@aws-sdk/client-s3": "^3.787.0",
"@discordjs/scripts": "workspace:^",
"@vercel/blob": "^0.23.4",
"@vercel/blob": "^0.27.3",
"@vercel/postgres": "^0.9.0",
"cloudflare": "^4.2.0",
"meilisearch": "^0.38.0",
"p-limit": "^6.1.0",
"tslib": "^2.6.3",
"undici": "6.19.8"
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",
"tslib": "^2.8.1",
"undici": "7.8.0"
},
"devDependencies": {
"@types/node": "^18.19.45",
"@vitest/coverage-v8": "^2.0.5",
"@types/node": "^22.14.0",
"@vitest/coverage-v8": "^3.1.1",
"cross-env": "^7.0.3",
"eslint": "^8.57.0",
"eslint-config-neon": "^0.1.62",
"eslint-formatter-pretty": "^6.0.1",
"prettier": "^3.3.3",
"tsup": "^8.2.4",
"turbo": "^2.0.14",
"typescript": "~5.5.4",
"vitest": "^2.0.5"
"prettier": "^3.5.3",
"terser": "^5.37.0",
"tsup": "^8.4.0",
"turbo": "^2.5.0",
"typescript": "~5.8.3",
"vitest": "^3.1.1"
},
"engines": {
"node": ">=18"

View File

@@ -9,7 +9,7 @@ runs:
with:
swap-size-gb: 10
- uses: pnpm/action-setup@v4.0.0
- uses: pnpm/action-setup@v4.1.0
name: Install pnpm
with:
run_install: false

View File

@@ -1,13 +1,26 @@
/* eslint-disable @typescript-eslint/no-loop-func */
import { readFile } from 'node:fs/promises';
import process from 'node:process';
import { getInput, setFailed } from '@actions/core';
import { create } from '@actions/glob';
import { PutObjectCommand, S3Client } from '@aws-sdk/client-s3';
import { put } from '@vercel/blob';
import { createPool } from '@vercel/postgres';
import Cloudflare from 'cloudflare';
import pLimit from 'p-limit';
if (!process.env.DATABASE_URL) {
setFailed('DATABASE_URL is not set');
if (
!process.env.DATABASE_URL ||
!process.env.CF_R2_DOCS_URL ||
!process.env.CF_R2_DOCS_ACCESS_KEY_ID ||
!process.env.CF_R2_DOCS_SECRET_ACCESS_KEY ||
!process.env.CF_R2_DOCS_BUCKET ||
!process.env.CF_R2_DOCS_BUCKET_URL ||
!process.env.CF_D1_DOCS_API_KEY ||
!process.env.CF_D1_DOCS_ID ||
!process.env.CF_ACCOUNT_ID
) {
setFailed('Missing environment variables');
}
const pkg = getInput('package') || '*';
@@ -17,6 +30,21 @@ const pool = createPool({
connectionString: process.env.DATABASE_URL,
});
const S3 = new S3Client({
region: 'auto',
endpoint: process.env.CF_R2_DOCS_URL!,
credentials: {
accessKeyId: process.env.CF_R2_DOCS_ACCESS_KEY_ID!,
secretAccessKey: process.env.CF_R2_DOCS_SECRET_ACCESS_KEY!,
},
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});
const client = new Cloudflare({
apiToken: process.env.CF_D1_DOCS_API_KEY,
});
const limit = pLimit(10);
const promises = [];
@@ -26,12 +54,14 @@ for await (const file of globber.globGenerator()) {
const data = await readFile(file, 'utf8');
try {
promises.push(
// eslint-disable-next-line @typescript-eslint/no-loop-func
limit(async () => {
console.log(`Uploading ${file} with ${version}...`);
const json = JSON.parse(data);
const name = json.name ?? json.n;
const { url } = await put(`${name.replace('@discordjs/', '')}/${version}.json`, data, {
const key = `${name.replace('@discordjs/', '')}/${version}.json`;
const { url } = await put(key, data, {
access: 'public',
addRandomSuffix: false,
});
@@ -39,6 +69,19 @@ for await (const file of globber.globGenerator()) {
'@discordjs/',
'',
)}, ${version}, ${url}) on conflict (name, version) do update set url = EXCLUDED.url`;
await S3.send(
new PutObjectCommand({
Bucket: process.env.CF_R2_DOCS_BUCKET,
Key: key,
Body: data,
}),
);
await client.d1.database.raw(process.env.CF_D1_DOCS_ID!, {
account_id: process.env.CF_ACCOUNT_ID!,
sql: `insert into documentation (name, version, url) values (?, ?, ?) on conflict (name, version) do update set url = excluded.url;`,
params: [name.replace('@discordjs/', ''), version, process.env.CF_R2_DOCS_BUCKET_URL + '/' + key],
});
}),
);
} catch (error) {

View File

@@ -1,32 +1,82 @@
/* eslint-disable @typescript-eslint/no-loop-func */
import { readFile } from 'node:fs/promises';
import { basename, dirname, relative, sep } from 'node:path';
import { cwd } from 'node:process';
import { getInput } from '@actions/core';
import process from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
import { setFailed, getInput } from '@actions/core';
import { create } from '@actions/glob';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { put } from '@vercel/blob';
import pLimit from 'p-limit';
import PQueue from 'p-queue';
if (
!process.env.CF_R2_DOCS_URL ||
!process.env.CF_R2_DOCS_ACCESS_KEY_ID ||
!process.env.CF_R2_DOCS_SECRET_ACCESS_KEY ||
!process.env.CF_R2_DOCS_BUCKET
) {
setFailed('Missing environment variables');
}
const pkg = getInput('package') || '*';
const version = getInput('version') || 'main';
const limit = pLimit(10);
const queue = new PQueue({ concurrency: 10, interval: 60_000, intervalCap: 1_000 });
const promises = [];
const failedUploads: string[] = [];
const S3 = new S3Client({
region: 'auto',
endpoint: process.env.CF_R2_DOCS_URL!,
credentials: {
accessKeyId: process.env.CF_R2_DOCS_ACCESS_KEY_ID!,
secretAccessKey: process.env.CF_R2_DOCS_SECRET_ACCESS_KEY!,
},
requestChecksumCalculation: 'WHEN_REQUIRED',
responseChecksumValidation: 'WHEN_REQUIRED',
});
const globber = await create(`packages/${pkg}/docs/${pkg}/split/*.api.json`);
console.log('Glob: ', await globber.glob());
for await (const file of globber.globGenerator()) {
const data = await readFile(file, 'utf8');
const pkgName = dirname(relative(cwd(), file)).split(sep)[1];
const pkgName = dirname(relative(process.cwd(), file)).split(sep)[1];
try {
promises.push(
// eslint-disable-next-line @typescript-eslint/no-loop-func
limit(async () => {
queue.add(async () => {
console.log(`Uploading ${file} with ${version} from ${pkgName}...`);
const name = basename(file).replace('main.', '');
await put(`rewrite/${pkgName}/${version}.${name}`, data, {
access: 'public',
addRandomSuffix: false,
});
async function upload(retries = 0) {
try {
await put(`rewrite/${pkgName}/${version}.${name}`, data, {
access: 'public',
addRandomSuffix: false,
});
await S3.send(
new PutObjectCommand({
Bucket: process.env.CF_R2_DOCS_BUCKET,
Key: `${pkgName}/${version}.${name}`,
Body: data,
}),
);
} catch (error) {
if (retries > 3) {
console.error(`Could not upload ${file} after 3 retries`, error);
failedUploads.push(name);
return;
}
if (typeof error === 'object' && error && 'retryAfter' in error && typeof error.retryAfter === 'number') {
await sleep(error.retryAfter * 1_000);
return upload(retries + 1);
} else {
console.error(`Could not upload ${file}`, error);
failedUploads.push(name);
}
}
}
await upload();
}),
);
} catch (error) {
@@ -36,6 +86,9 @@ for await (const file of globber.globGenerator()) {
try {
await Promise.all(promises);
if (failedUploads.length) {
setFailed(`Failed to upload ${failedUploads.length} files: ${failedUploads.join(', ')}`);
}
} catch (error) {
console.log(error);
}

View File

@@ -0,0 +1,10 @@
{
"$schema": "https://json.schemastore.org/tsconfig.json",
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"skipLibCheck": true
},
"include": ["__tests__/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -2,15 +2,7 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import {
type DocNode,
type DocPlainText,
DocDeclarationReference,
DocNodeKind,
TSDocConfiguration,
DocMemberReference,
DocMemberIdentifier,
} from '@microsoft/tsdoc';
import { TSDocConfiguration } from '@microsoft/tsdoc';
import type { DeclarationReference } from '@microsoft/tsdoc/lib-commonjs/beta/DeclarationReference.js';
import { InternalError } from '@rushstack/node-core-library';
import type { IExcerptToken, IExcerptTokenRange } from '../index.js';
@@ -50,11 +42,6 @@ export interface IApiItemContainerJson extends IApiItemJson {
preserveMemberOrder?: boolean;
}
interface Mixin {
declarationReference: DocDeclarationReference;
typeParameters: IExcerptTokenRange[];
}
interface ExcerptTokenRangeInDeclaredItem {
item: ApiDeclaredItem;
range: IExcerptTokenRange;
@@ -406,52 +393,14 @@ export function ApiItemContainerMixin<TBaseClass extends IApiItemConstructor>(
}
}
const findPlainTextNode = (node: DocNode): DocPlainText | undefined => {
switch (node.kind) {
case DocNodeKind.PlainText:
return node as DocPlainText;
default:
for (const child of node.getChildNodes()) {
const result = findPlainTextNode(child);
if (result) return result;
}
}
return undefined;
};
// Interfaces can extend multiple interfaces, so iterate through all of them.
// Also Classes can have multiple mixins
const extendedItems: IMappedTypeParameters[] = [];
let extendsTypes: readonly (HeritageType | Mixin)[] | undefined;
let extendsTypes: readonly HeritageType[] | undefined;
switch (next.item.kind) {
case ApiItemKind.Class: {
const apiClass: ApiClass = next.item as ApiClass;
const configuration = apiClass.tsdocComment?.configuration ?? new TSDocConfiguration();
const mixins =
apiClass.tsdocComment?.customBlocks
.filter(
(block) => block.blockTag.tagName === '@mixes', // &&
// block.getChildNodes().some((node) => node.kind === DocNodeKind.PlainText),
)
.map(findPlainTextNode)
.filter((block) => block !== undefined)
.map((block) => ({
declarationReference: new DocDeclarationReference({
configuration,
memberReferences: block.text.split('.').map(
(part, index) =>
new DocMemberReference({
configuration,
hasDot: index > 0,
memberIdentifier: new DocMemberIdentifier({ configuration, identifier: part }),
}),
),
}),
typeParameters: [] as IExcerptTokenRange[],
})) ?? [];
extendsTypes = apiClass.extendsType ? [apiClass.extendsType, ...mixins] : [...mixins];
extendsTypes = apiClass.extendsType ? [apiClass.extendsType] : [];
break;
}
@@ -476,38 +425,30 @@ export function ApiItemContainerMixin<TBaseClass extends IApiItemConstructor>(
}
for (const extendsType of extendsTypes) {
let canonicalReference: DeclarationReference | DocDeclarationReference;
if ('excerpt' in extendsType) {
// We want to find the reference token associated with the actual inherited declaration.
// In every case we support, this is the first reference token. For example:
//
// ```
// export class A extends B {}
// ^
// export class A extends B<C> {}
// ^
// export class A extends B.C {}
// ^^^
// ```
const firstReferenceToken: ExcerptToken | undefined = extendsType.excerpt.spannedTokens.find(
(token: ExcerptToken) => {
return token.kind === ExcerptTokenKind.Reference && token.canonicalReference;
},
);
// We want to find the reference token associated with the actual inherited declaration.
// In every case we support, this is the first reference token. For example:
//
// ```
// export class A extends B {}
// ^
// export class A extends B<C> {}
// ^
// export class A extends B.C {}
// ^^^
// ```
const firstReferenceToken: ExcerptToken | undefined = extendsType.excerpt.spannedTokens.find(
(token: ExcerptToken) => {
return token.kind === ExcerptTokenKind.Reference && token.canonicalReference;
},
);
if (!firstReferenceToken) {
messages.push({
messageId: FindApiItemsMessageId.ExtendsClauseMissingReference,
text: `Unable to analyze extends clause ${extendsType.excerpt.text} of API item ${next.item.displayName} because no canonical reference was found`,
});
maybeIncompleteResult = true;
continue;
}
canonicalReference = firstReferenceToken.canonicalReference!;
} else {
// extendsType is a Mixin
canonicalReference = extendsType.declarationReference;
if (!firstReferenceToken) {
messages.push({
messageId: FindApiItemsMessageId.ExtendsClauseMissingReference,
text: `Unable to analyze extends clause ${extendsType.excerpt.text} of API item ${next.item.displayName} because no canonical reference was found`,
});
maybeIncompleteResult = true;
continue;
}
const apiModel: ApiModel | undefined = this.getAssociatedModel();
@@ -520,9 +461,10 @@ export function ApiItemContainerMixin<TBaseClass extends IApiItemConstructor>(
continue;
}
const canonicalReference: DeclarationReference = firstReferenceToken.canonicalReference!;
const apiItemResult: IResolveDeclarationReferenceResult = apiModel.resolveDeclarationReference(
canonicalReference,
this,
undefined,
);
const apiItem: ApiItem | undefined = apiItemResult.resolvedApiItem;

View File

@@ -294,11 +294,7 @@ export class ApiPackage extends ApiItemContainerMixin(ApiNameMixin(ApiDocumented
const tsdocConfiguration: TSDocConfiguration = new TSDocConfiguration();
if (
versionToDeserialize >= ApiJsonSchemaVersion.V_1004 &&
'tsdocConfig' in jsonObject.metadata &&
'$schema' in jsonObject.metadata.tsdocConfig
) {
if (versionToDeserialize >= ApiJsonSchemaVersion.V_1004 && 'tsdocConfiguration' in jsonObject) {
const tsdocConfigFile: TSDocConfigFile = TSDocConfigFile.loadFromObject(jsonObject.metadata.tsdocConfig);
if (tsdocConfigFile.hasErrors) {
throw new Error(`Error loading ${apiJsonFilename}:\n` + tsdocConfigFile.getErrorSummary());
@@ -425,8 +421,6 @@ export class ApiPackage extends ApiItemContainerMixin(ApiNameMixin(ApiDocumented
for (const key of Object.keys(item)) {
if (key === 'dependencies') {
result[MinifyJSONMapping.dependencies] = item.dependencies;
} else if (key === 'tsdocConfig') {
result[MinifyJSONMapping.tsdocConfig] = item.tsdocConfig;
} else
result[MinifyJSONMapping[key as keyof typeof MinifyJSONMapping]] =
typeof item[key] === 'object' ? mapper(item[key]) : item[key];
@@ -447,8 +441,6 @@ export class ApiPackage extends ApiItemContainerMixin(ApiNameMixin(ApiDocumented
for (const key of Object.keys(item)) {
if (key === MinifyJSONMapping.dependencies) {
result.dependencies = item[MinifyJSONMapping.dependencies];
} else if (key === MinifyJSONMapping.tsdocConfig) {
result.tsdocConfig = item[MinifyJSONMapping.tsdocConfig];
} else
result[
Object.keys(MinifyJSONMapping).find(

View File

@@ -32,10 +32,6 @@
{
"tagName": "@preapproved",
"syntaxKind": "modifier"
},
{
"tagName": "@mixes",
"syntaxKind": "block"
}
],

View File

@@ -1270,7 +1270,7 @@ export class ApiModelGenerator {
const apiItemMetadata: ApiItemMetadata = this._collector.fetchApiItemMetadata(astDeclaration);
const docComment: tsdoc.DocComment | undefined = jsDoc
? this._tsDocParser.parseString(
`/**\n * ${this._fixLinkTags(jsDoc.description) ?? ''}${jsDoc.default ? ` (default: ${this._escapeSpecialChars(jsDoc.default)})` : ''}\n${
`/**\n * ${this._fixLinkTags(jsDoc.description) ?? ''}${jsDoc.default === undefined ? '' : ` (default: ${this._escapeSpecialChars(jsDoc.default)})`}\n${
'see' in jsDoc ? jsDoc.see.map((see) => ` * @see ${see}\n`).join('') : ''
}${'readonly' in jsDoc && jsDoc.readonly ? ' * @readonly\n' : ''}${
'deprecated' in jsDoc && jsDoc.deprecated
@@ -1348,7 +1348,7 @@ export class ApiModelGenerator {
const apiItemMetadata: ApiItemMetadata = this._collector.fetchApiItemMetadata(astDeclaration);
const docComment: tsdoc.DocComment | undefined = jsDoc
? this._tsDocParser.parseString(
`/**\n * ${this._fixLinkTags(jsDoc.description) ?? ''}${jsDoc.default ? `\n * @defaultValue ${this._escapeSpecialChars(jsDoc.default)}` : ''}\n${
`/**\n * ${this._fixLinkTags(jsDoc.description) ?? ''}${jsDoc.default === undefined ? '' : `\n * @defaultValue ${this._escapeSpecialChars(jsDoc.default)}`}\n${
'see' in jsDoc ? jsDoc.see.map((see) => ` * @see ${see}\n`).join('') : ''
}${'readonly' in jsDoc && jsDoc.readonly ? ' * @readonly\n' : ''}${
'deprecated' in jsDoc && jsDoc.deprecated

View File

@@ -9,7 +9,8 @@
<a href="https://www.npmjs.com/package/@discordjs/brokers"><img src="https://img.shields.io/npm/v/@discordjs/brokers.svg?maxAge=3600" alt="npm version" /></a>
<a href="https://www.npmjs.com/package/@discordjs/brokers"><img src="https://img.shields.io/npm/dt/@discordjs/brokers.svg?maxAge=3600" alt="npm downloads" /></a>
<a href="https://github.com/discordjs/discord.js/actions"><img src="https://github.com/discordjs/discord.js/actions/workflows/test.yml/badge.svg" alt="Build status" /></a>
<a href="https://codecov.io/gh/discordjs/discord.js" ><img src="https://codecov.io/gh/discordjs/discord.js/branch/main/graph/badge.svg?precision=2&flag=brokers" alt="Code coverage" /></a>
<a href="https://github.com/discordjs/discord.js/commits/main/packages/brokers"><img alt="Last commit." src="https://img.shields.io/github/last-commit/discordjs/discord.js?logo=github&logoColor=ffffff&path=packages%2Fbrokers"></a>
<a href="https://codecov.io/gh/discordjs/discord.js"><img src="https://codecov.io/gh/discordjs/discord.js/branch/main/graph/badge.svg?precision=2&flag=brokers" alt="Code coverage" /></a>
</p>
<p>
<a href="https://vercel.com/?utm_source=discordjs&utm_campaign=oss"><img src="https://raw.githubusercontent.com/discordjs/discord.js/main/.github/powered-by-vercel.svg" alt="Vercel" /></a>

View File

@@ -9,7 +9,8 @@
<a href="https://www.npmjs.com/package/@discordjs/builders"><img src="https://img.shields.io/npm/v/@discordjs/builders.svg?maxAge=3600" alt="npm version" /></a>
<a href="https://www.npmjs.com/package/@discordjs/builders"><img src="https://img.shields.io/npm/dt/@discordjs/builders.svg?maxAge=3600" alt="npm downloads" /></a>
<a href="https://github.com/discordjs/discord.js/actions"><img src="https://github.com/discordjs/discord.js/actions/workflows/test.yml/badge.svg" alt="Build status" /></a>
<a href="https://codecov.io/gh/discordjs/discord.js" ><img src="https://codecov.io/gh/discordjs/discord.js/branch/main/graph/badge.svg?precision=2&flag=builders" alt="Code coverage" /></a>
<a href="https://github.com/discordjs/discord.js/commits/main/packages/builders"><img alt="Last commit." src="https://img.shields.io/github/last-commit/discordjs/discord.js?logo=github&logoColor=ffffff&path=packages%2Fbuilders"></a>
<a href="https://codecov.io/gh/discordjs/discord.js"><img src="https://codecov.io/gh/discordjs/discord.js/branch/main/graph/badge.svg?precision=2&flag=builders" alt="Code coverage" /></a>
</p>
<p>
<a href="https://vercel.com/?utm_source=discordjs&utm_campaign=oss"><img src="https://raw.githubusercontent.com/discordjs/discord.js/main/.github/powered-by-vercel.svg" alt="Vercel" /></a>

View File

@@ -7,8 +7,8 @@ import {
import { describe, test, expect } from 'vitest';
import {
ActionRowBuilder,
ButtonBuilder,
createComponentBuilder,
PrimaryButtonBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
} from '../../src/index.js';
@@ -41,14 +41,21 @@ const rowWithSelectMenuData: APIActionRowComponent<APIMessageActionRowComponent>
value: 'two',
},
],
max_values: 2,
min_values: 2,
max_values: 10,
min_values: 12,
},
],
};
describe('Action Row Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid components THEN do not throw', () => {
expect(() => new ActionRowBuilder().addComponents(new ButtonBuilder())).not.toThrowError();
expect(() => new ActionRowBuilder().setComponents(new ButtonBuilder())).not.toThrowError();
expect(() => new ActionRowBuilder().addComponents([new ButtonBuilder()])).not.toThrowError();
expect(() => new ActionRowBuilder().setComponents([new ButtonBuilder()])).not.toThrowError();
});
test('GIVEN valid JSON input THEN valid JSON output is given', () => {
const actionRowData: APIActionRowComponent<APIMessageActionRowComponent> = {
type: ComponentType.ActionRow,
@@ -65,10 +72,22 @@ describe('Action Row Components', () => {
style: ButtonStyle.Link,
url: 'https://google.com',
},
{
type: ComponentType.StringSelect,
placeholder: 'test',
custom_id: 'test',
options: [
{
label: 'option',
value: 'option',
},
],
},
],
};
expect(new ActionRowBuilder(actionRowData).toJSON()).toEqual(actionRowData);
expect(new ActionRowBuilder().toJSON()).toEqual({ type: ComponentType.ActionRow, components: [] });
expect(() => createComponentBuilder({ type: ComponentType.ActionRow, components: [] })).not.toThrowError();
});
@@ -101,23 +120,24 @@ describe('Action Row Components', () => {
value: 'two',
},
],
max_values: 1,
min_values: 1,
max_values: 10,
min_values: 12,
},
],
};
expect(new ActionRowBuilder(rowWithButtonData).toJSON()).toEqual(rowWithButtonData);
expect(new ActionRowBuilder(rowWithSelectMenuData).toJSON()).toEqual(rowWithSelectMenuData);
expect(new ActionRowBuilder().toJSON()).toEqual({ type: ComponentType.ActionRow, components: [] });
expect(() => createComponentBuilder({ type: ComponentType.ActionRow, components: [] })).not.toThrowError();
});
test('GIVEN valid builder options THEN valid JSON output is given 2', () => {
const button = new PrimaryButtonBuilder().setLabel('test').setCustomId('123');
const button = new ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123');
const selectMenu = new StringSelectMenuBuilder()
.setCustomId('1234')
.setMaxValues(2)
.setMinValues(2)
.setMaxValues(10)
.setMinValues(12)
.setOptions(
new StringSelectMenuOptionBuilder().setLabel('one').setValue('one'),
new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'),
@@ -127,39 +147,10 @@ describe('Action Row Components', () => {
new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'),
]);
expect(new ActionRowBuilder().addPrimaryButtonComponents(button).toJSON()).toEqual(rowWithButtonData);
expect(new ActionRowBuilder().addStringSelectMenuComponent(selectMenu).toJSON()).toEqual(rowWithSelectMenuData);
expect(new ActionRowBuilder().addPrimaryButtonComponents([button]).toJSON()).toEqual(rowWithButtonData);
});
test('GIVEN 2 select menus THEN it throws', () => {
const selectMenu = new StringSelectMenuBuilder()
.setCustomId('1234')
.setOptions(
new StringSelectMenuOptionBuilder().setLabel('one').setValue('one'),
new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'),
);
expect(() =>
new ActionRowBuilder()
.addStringSelectMenuComponent(selectMenu)
.addStringSelectMenuComponent(selectMenu)
.toJSON(),
).toThrowError();
});
test('GIVEN a button and a select menu THEN it throws', () => {
const button = new PrimaryButtonBuilder().setLabel('test').setCustomId('123');
const selectMenu = new StringSelectMenuBuilder()
.setCustomId('1234')
.setOptions(
new StringSelectMenuOptionBuilder().setLabel('one').setValue('one'),
new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'),
);
expect(() =>
new ActionRowBuilder().addStringSelectMenuComponent(selectMenu).addPrimaryButtonComponents(button).toJSON(),
).toThrowError();
expect(new ActionRowBuilder().addComponents(button).toJSON()).toEqual(rowWithButtonData);
expect(new ActionRowBuilder().addComponents(selectMenu).toJSON()).toEqual(rowWithSelectMenuData);
expect(new ActionRowBuilder().addComponents([button]).toJSON()).toEqual(rowWithButtonData);
expect(new ActionRowBuilder().addComponents([selectMenu]).toJSON()).toEqual(rowWithSelectMenuData);
});
});
});

View File

@@ -5,20 +5,45 @@ import {
type APIButtonComponentWithURL,
} from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { PrimaryButtonBuilder, PremiumButtonBuilder, LinkButtonBuilder } from '../../src/index.js';
import { buttonLabelValidator, buttonStyleValidator } from '../../src/components/Assertions.js';
import { ButtonBuilder } from '../../src/components/button/Button.js';
const buttonComponent = () => new ButtonBuilder();
const longStr =
'looooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooooong';
describe('Button Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid label THEN validator does not throw', () => {
expect(() => buttonLabelValidator.parse('foobar')).not.toThrowError();
});
test('GIVEN invalid label THEN validator does throw', () => {
expect(() => buttonLabelValidator.parse(null)).toThrowError();
expect(() => buttonLabelValidator.parse('')).toThrowError();
expect(() => buttonLabelValidator.parse(longStr)).toThrowError();
});
test('GIVEN valid style THEN validator does not throw', () => {
expect(() => buttonStyleValidator.parse(3)).not.toThrowError();
expect(() => buttonStyleValidator.parse(ButtonStyle.Secondary)).not.toThrowError();
});
test('GIVEN invalid style THEN validator does throw', () => {
expect(() => buttonStyleValidator.parse(7)).toThrowError();
});
test('GIVEN valid fields THEN builder does not throw', () => {
expect(() => new PrimaryButtonBuilder().setCustomId('custom').setLabel('test')).not.toThrowError();
expect(() =>
buttonComponent().setCustomId('custom').setStyle(ButtonStyle.Primary).setLabel('test'),
).not.toThrowError();
expect(() => {
const button = new PrimaryButtonBuilder()
const button = buttonComponent()
.setCustomId('custom')
.setLabel('test')
.setStyle(ButtonStyle.Primary)
.setDisabled(true)
.setEmoji({ name: 'test' });
@@ -26,41 +51,111 @@ describe('Button Components', () => {
}).not.toThrowError();
expect(() => {
const button = new PremiumButtonBuilder().setSKUId('123456789012345678');
const button = buttonComponent().setSKUId('123456789012345678').setStyle(ButtonStyle.Premium);
button.toJSON();
}).not.toThrowError();
expect(() => new LinkButtonBuilder().setURL('https://google.com')).not.toThrowError();
expect(() => buttonComponent().setURL('https://google.com')).not.toThrowError();
});
test('GIVEN invalid fields THEN build does throw', () => {
expect(() => {
new PrimaryButtonBuilder().setCustomId(longStr).toJSON();
buttonComponent().setCustomId(longStr);
}).toThrowError();
expect(() => {
const button = buttonComponent()
.setCustomId('custom')
.setStyle(ButtonStyle.Primary)
.setDisabled(true)
.setLabel('test')
.setURL('https://google.com')
.setEmoji({ name: 'test' });
button.toJSON();
}).toThrowError();
expect(() => {
// @ts-expect-error: Invalid emoji
const button = new PrimaryButtonBuilder().setEmoji('test');
const button = buttonComponent().setEmoji('test');
button.toJSON();
}).toThrowError();
expect(() => {
const button = new PrimaryButtonBuilder();
const button = buttonComponent().setStyle(ButtonStyle.Primary);
button.toJSON();
}).toThrowError();
expect(() => {
const button = new PrimaryButtonBuilder().setCustomId('test');
const button = buttonComponent().setStyle(ButtonStyle.Primary).setCustomId('test');
button.toJSON();
}).toThrowError();
expect(() => {
const button = buttonComponent().setStyle(ButtonStyle.Link);
button.toJSON();
}).toThrowError();
expect(() => {
const button = buttonComponent().setStyle(ButtonStyle.Primary).setLabel('test').setURL('https://google.com');
button.toJSON();
}).toThrowError();
expect(() => {
const button = buttonComponent().setStyle(ButtonStyle.Link).setLabel('test');
button.toJSON();
}).toThrowError();
expect(() => {
const button = buttonComponent().setStyle(ButtonStyle.Primary).setSKUId('123456789012345678');
button.toJSON();
}).toThrowError();
expect(() => {
const button = buttonComponent()
.setStyle(ButtonStyle.Secondary)
.setLabel('button')
.setSKUId('123456789012345678');
button.toJSON();
}).toThrowError();
expect(() => {
const button = buttonComponent()
.setStyle(ButtonStyle.Success)
.setEmoji({ name: '😇' })
.setSKUId('123456789012345678');
button.toJSON();
}).toThrowError();
expect(() => {
const button = buttonComponent()
.setStyle(ButtonStyle.Danger)
.setCustomId('test')
.setSKUId('123456789012345678');
button.toJSON();
}).toThrowError();
expect(() => {
const button = buttonComponent()
.setStyle(ButtonStyle.Link)
.setURL('https://google.com')
.setSKUId('123456789012345678');
button.toJSON();
}).toThrowError();
// @ts-expect-error: Invalid style
expect(() => new PrimaryButtonBuilder().setCustomId('hi').setStyle(24).toJSON()).toThrowError();
expect(() => new PrimaryButtonBuilder().setCustomId('hi').setLabel(longStr).toJSON()).toThrowError();
expect(() => buttonComponent().setStyle(24)).toThrowError();
expect(() => buttonComponent().setLabel(longStr)).toThrowError();
// @ts-expect-error: Invalid parameter for disabled
expect(() => new PrimaryButtonBuilder().setCustomId('hi').setDisabled(0).toJSON()).toThrowError();
expect(() => buttonComponent().setDisabled(0)).toThrowError();
// @ts-expect-error: Invalid emoji
expect(() => new PrimaryButtonBuilder().setCustomId('hi').setEmoji('foo').toJSON()).toThrowError();
expect(() => buttonComponent().setEmoji('foo')).toThrowError();
expect(() => buttonComponent().setURL('foobar')).toThrowError();
});
test('GiVEN valid input THEN valid JSON outputs are given', () => {
@@ -72,12 +167,13 @@ describe('Button Components', () => {
disabled: true,
};
expect(new PrimaryButtonBuilder(interactionData).toJSON()).toEqual(interactionData);
expect(new ButtonBuilder(interactionData).toJSON()).toEqual(interactionData);
expect(
new PrimaryButtonBuilder()
buttonComponent()
.setCustomId(interactionData.custom_id)
.setLabel(interactionData.label!)
.setStyle(interactionData.style)
.setDisabled(interactionData.disabled)
.toJSON(),
).toEqual(interactionData);
@@ -90,7 +186,9 @@ describe('Button Components', () => {
url: 'https://google.com',
};
expect(new LinkButtonBuilder(linkData).toJSON()).toEqual(linkData);
expect(new ButtonBuilder(linkData).toJSON()).toEqual(linkData);
expect(buttonComponent().setLabel(linkData.label!).setDisabled(true).setURL(linkData.url));
});
});
});

View File

@@ -11,14 +11,14 @@ import {
import { describe, test, expect } from 'vitest';
import {
ActionRowBuilder,
ButtonBuilder,
createComponentBuilder,
CustomIdButtonBuilder,
StringSelectMenuBuilder,
TextInputBuilder,
} from '../../src/index.js';
describe('createComponentBuilder', () => {
test.each([StringSelectMenuBuilder, TextInputBuilder])(
test.each([ButtonBuilder, StringSelectMenuBuilder, TextInputBuilder])(
'passing an instance of %j should return itself',
(Builder) => {
const builder = new Builder();
@@ -42,7 +42,7 @@ describe('createComponentBuilder', () => {
type: ComponentType.Button,
};
expect(createComponentBuilder(button)).toBeInstanceOf(CustomIdButtonBuilder);
expect(createComponentBuilder(button)).toBeInstanceOf(ButtonBuilder);
});
test('GIVEN a select menu component THEN returns a StringSelectMenuBuilder', () => {

View File

@@ -3,7 +3,6 @@ import { describe, test, expect } from 'vitest';
import { StringSelectMenuBuilder, StringSelectMenuOptionBuilder } from '../../src/index.js';
const selectMenu = () => new StringSelectMenuBuilder();
const selectMenuWithId = () => new StringSelectMenuBuilder({ custom_id: 'hi' });
const selectMenuOption = () => new StringSelectMenuOptionBuilder();
const longStr = 'a'.repeat(256);
@@ -17,10 +16,10 @@ const selectMenuOptionData: APISelectMenuOption = {
};
const selectMenuDataWithoutOptions = {
type: ComponentType.StringSelect,
type: ComponentType.SelectMenu,
custom_id: 'test',
max_values: 1,
min_values: 1,
max_values: 10,
min_values: 3,
disabled: true,
placeholder: 'test',
} as const;
@@ -110,87 +109,49 @@ describe('Select Menu Components', () => {
});
test('GIVEN invalid inputs THEN Select Menu does throw', () => {
expect(() => selectMenu().setCustomId(longStr).toJSON()).toThrowError();
expect(() => selectMenuWithId().setMaxValues(30).toJSON()).toThrowError();
expect(() => selectMenuWithId().setMinValues(-20).toJSON()).toThrowError();
expect(() => selectMenu().setCustomId(longStr)).toThrowError();
expect(() => selectMenu().setMaxValues(30)).toThrowError();
expect(() => selectMenu().setMinValues(-20)).toThrowError();
// @ts-expect-error: Invalid disabled value
expect(() => selectMenuWithId().setDisabled(0).toJSON()).toThrowError();
expect(() => selectMenuWithId().setPlaceholder(longStr).toJSON()).toThrowError();
expect(() => selectMenu().setDisabled(0)).toThrowError();
expect(() => selectMenu().setPlaceholder(longStr)).toThrowError();
// @ts-expect-error: Invalid option
expect(() => selectMenuWithId().addOptions({ label: 'test' }).toJSON()).toThrowError();
expect(() => selectMenuWithId().addOptions({ label: longStr, value: 'test' }).toJSON()).toThrowError();
expect(() => selectMenuWithId().addOptions({ value: longStr, label: 'test' }).toJSON()).toThrowError();
expect(() =>
selectMenuWithId().addOptions({ label: 'test', value: 'test', description: longStr }).toJSON(),
).toThrowError();
expect(() =>
// @ts-expect-error: Invalid option
selectMenuWithId().addOptions({ label: 'test', value: 'test', default: 100 }).toJSON(),
).toThrowError();
expect(() => selectMenu().addOptions({ label: 'test' })).toThrowError();
expect(() => selectMenu().addOptions({ label: longStr, value: 'test' })).toThrowError();
expect(() => selectMenu().addOptions({ value: longStr, label: 'test' })).toThrowError();
expect(() => selectMenu().addOptions({ label: 'test', value: 'test', description: longStr })).toThrowError();
// @ts-expect-error: Invalid option
expect(() => selectMenuWithId().addOptions({ value: 'test' }).toJSON()).toThrowError();
expect(() => selectMenu().addOptions({ label: 'test', value: 'test', default: 100 })).toThrowError();
// @ts-expect-error: Invalid option
expect(() => selectMenuWithId().addOptions({ default: true }).toJSON()).toThrowError();
expect(() =>
selectMenuWithId()
// @ts-expect-error: Invalid option
.addOptions([{ label: 'test' }])
.toJSON(),
).toThrowError();
expect(() =>
selectMenuWithId()
.addOptions([{ label: longStr, value: 'test' }])
.toJSON(),
).toThrowError();
expect(() =>
selectMenuWithId()
.addOptions([{ value: longStr, label: 'test' }])
.toJSON(),
).toThrowError();
expect(() =>
selectMenuWithId()
.addOptions([{ label: 'test', value: 'test', description: longStr }])
.toJSON(),
).toThrowError();
expect(() =>
selectMenuWithId()
// @ts-expect-error: Invalid option
.addOptions([{ label: 'test', value: 'test', default: 100 }])
.toJSON(),
).toThrowError();
expect(() =>
selectMenuWithId()
// @ts-expect-error: Invalid option
.addOptions([{ value: 'test' }])
.toJSON(),
).toThrowError();
expect(() =>
selectMenuWithId()
// @ts-expect-error: Invalid option
.addOptions([{ default: true }])
.toJSON(),
).toThrowError();
expect(() => selectMenu().addOptions({ value: 'test' })).toThrowError();
// @ts-expect-error: Invalid option
expect(() => selectMenu().addOptions({ default: true })).toThrowError();
// @ts-expect-error: Invalid option
expect(() => selectMenu().addOptions([{ label: 'test' }])).toThrowError();
expect(() => selectMenu().addOptions([{ label: longStr, value: 'test' }])).toThrowError();
expect(() => selectMenu().addOptions([{ value: longStr, label: 'test' }])).toThrowError();
expect(() => selectMenu().addOptions([{ label: 'test', value: 'test', description: longStr }])).toThrowError();
// @ts-expect-error: Invalid option
expect(() => selectMenu().addOptions([{ label: 'test', value: 'test', default: 100 }])).toThrowError();
// @ts-expect-error: Invalid option
expect(() => selectMenu().addOptions([{ value: 'test' }])).toThrowError();
// @ts-expect-error: Invalid option
expect(() => selectMenu().addOptions([{ default: true }])).toThrowError();
const tooManyOptions = Array.from<APISelectMenuOption>({ length: 26 }).fill({ label: 'test', value: 'test' });
expect(() =>
selectMenu()
.setOptions(...tooManyOptions)
.toJSON(),
).toThrowError();
expect(() => selectMenu().setOptions(tooManyOptions).toJSON()).toThrowError();
expect(() => selectMenu().setOptions(...tooManyOptions)).toThrowError();
expect(() => selectMenu().setOptions(tooManyOptions)).toThrowError();
expect(() =>
selectMenu()
.addOptions({ label: 'test', value: 'test' })
.addOptions(...tooManyOptions)
.toJSON(),
.addOptions(...tooManyOptions),
).toThrowError();
expect(() =>
selectMenu()
.addOptions([{ label: 'test', value: 'test' }])
.addOptions(tooManyOptions)
.toJSON(),
.addOptions(tooManyOptions),
).toThrowError();
expect(() => {
@@ -201,8 +162,7 @@ describe('Select Menu Components', () => {
.setDefault(-1)
// @ts-expect-error: Invalid emoji
.setEmoji({ name: 1 })
.setDescription(longStr)
.toJSON();
.setDescription(longStr);
}).toThrowError();
});
@@ -252,16 +212,17 @@ describe('Select Menu Components', () => {
).toStrictEqual([selectMenuOptionData]);
expect(() =>
makeStringSelectMenuWithOptions()
.spliceOptions(0, 0, ...Array.from({ length: 26 }, () => selectMenuOptionData))
.toJSON(),
makeStringSelectMenuWithOptions().spliceOptions(
0,
0,
...Array.from({ length: 26 }, () => selectMenuOptionData),
),
).toThrowError();
expect(() =>
makeStringSelectMenuWithOptions()
.setOptions(Array.from({ length: 25 }, () => selectMenuOptionData))
.spliceOptions(-1, 2, selectMenuOptionData, selectMenuOptionData)
.toJSON(),
.spliceOptions(-1, 2, selectMenuOptionData, selectMenuOptionData),
).toThrowError();
});
});

View File

@@ -1,5 +1,13 @@
import { ComponentType, TextInputStyle, type APITextInputComponent } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import {
labelValidator,
maxLengthValidator,
minLengthValidator,
placeholderValidator,
valueValidator,
textInputStyleValidator,
} from '../../src/components/textInput/Assertions.js';
import { TextInputBuilder } from '../../src/components/textInput/TextInput.js';
const superLongStr = 'a'.repeat(5_000);
@@ -8,6 +16,56 @@ const textInputComponent = () => new TextInputBuilder();
describe('Text Input Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid label THEN validator does not throw', () => {
expect(() => labelValidator.parse('foobar')).not.toThrowError();
});
test('GIVEN invalid label THEN validator does throw', () => {
expect(() => labelValidator.parse(24)).toThrowError();
expect(() => labelValidator.parse(undefined)).toThrowError();
});
test('GIVEN valid style THEN validator does not throw', () => {
expect(() => textInputStyleValidator.parse(TextInputStyle.Paragraph)).not.toThrowError();
expect(() => textInputStyleValidator.parse(TextInputStyle.Short)).not.toThrowError();
});
test('GIVEN invalid style THEN validator does throw', () => {
expect(() => textInputStyleValidator.parse(24)).toThrowError();
});
test('GIVEN valid min length THEN validator does not throw', () => {
expect(() => minLengthValidator.parse(10)).not.toThrowError();
});
test('GIVEN invalid min length THEN validator does throw', () => {
expect(() => minLengthValidator.parse(-1)).toThrowError();
});
test('GIVEN valid max length THEN validator does not throw', () => {
expect(() => maxLengthValidator.parse(10)).not.toThrowError();
});
test('GIVEN invalid min length THEN validator does throw 2', () => {
expect(() => maxLengthValidator.parse(4_001)).toThrowError();
});
test('GIVEN valid value THEN validator does not throw', () => {
expect(() => valueValidator.parse('foobar')).not.toThrowError();
});
test('GIVEN invalid value THEN validator does throw', () => {
expect(() => valueValidator.parse(superLongStr)).toThrowError();
});
test('GIVEN valid placeholder THEN validator does not throw', () => {
expect(() => placeholderValidator.parse('foobar')).not.toThrowError();
});
test('GIVEN invalid value THEN validator does throw 2', () => {
expect(() => placeholderValidator.parse(superLongStr)).toThrowError();
});
test('GIVEN valid fields THEN builder does not throw', () => {
expect(() => {
textInputComponent().setCustomId('foobar').setLabel('test').setStyle(TextInputStyle.Paragraph).toJSON();
@@ -26,7 +84,9 @@ describe('Text Input Components', () => {
}).not.toThrowError();
expect(() => {
textInputComponent().setCustomId('Custom').setLabel('Guess').setStyle(TextInputStyle.Short).toJSON();
// Issue #8107
// @ts-expect-error: Shapeshift maps the enum key to the value when parsing
textInputComponent().setCustomId('Custom').setLabel('Guess').setStyle('Short').toJSON();
}).not.toThrowError();
});
});

View File

@@ -1,516 +0,0 @@
import {
ApplicationCommandType,
ApplicationIntegrationType,
ChannelType,
InteractionContextType,
PermissionFlagsBits,
} from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import {
ChatInputCommandBooleanOption,
ChatInputCommandBuilder,
ChatInputCommandChannelOption,
ChatInputCommandIntegerOption,
ChatInputCommandMentionableOption,
ChatInputCommandNumberOption,
ChatInputCommandRoleOption,
ChatInputCommandAttachmentOption,
ChatInputCommandStringOption,
ChatInputCommandSubcommandBuilder,
ChatInputCommandSubcommandGroupBuilder,
ChatInputCommandUserOption,
} from '../../../src/index.js';
const getBuilder = () => new ChatInputCommandBuilder();
const getNamedBuilder = () => getBuilder().setName('example').setDescription('Example command');
const getStringOption = () => new ChatInputCommandStringOption().setName('owo').setDescription('Testing 123');
const getIntegerOption = () => new ChatInputCommandIntegerOption().setName('owo').setDescription('Testing 123');
const getNumberOption = () => new ChatInputCommandNumberOption().setName('owo').setDescription('Testing 123');
const getBooleanOption = () => new ChatInputCommandBooleanOption().setName('owo').setDescription('Testing 123');
const getUserOption = () => new ChatInputCommandUserOption().setName('owo').setDescription('Testing 123');
const getChannelOption = () => new ChatInputCommandChannelOption().setName('owo').setDescription('Testing 123');
const getRoleOption = () => new ChatInputCommandRoleOption().setName('owo').setDescription('Testing 123');
const getAttachmentOption = () => new ChatInputCommandAttachmentOption().setName('owo').setDescription('Testing 123');
const getMentionableOption = () => new ChatInputCommandMentionableOption().setName('owo').setDescription('Testing 123');
const getSubcommandGroup = () =>
new ChatInputCommandSubcommandGroupBuilder().setName('owo').setDescription('Testing 123');
const getSubcommand = () => new ChatInputCommandSubcommandBuilder().setName('owo').setDescription('Testing 123');
class Collection {
public readonly [Symbol.toStringTag] = 'Map';
}
describe('ChatInput Commands', () => {
describe('ChatInputCommandBuilder', () => {
describe('Builder with no options', () => {
test('GIVEN empty builder THEN throw error when calling toJSON', () => {
expect(() => getBuilder().toJSON()).toThrowError();
});
test('GIVEN valid builder THEN does not throw error', () => {
expect(() => getBuilder().setName('example').setDescription('Example command').toJSON()).not.toThrowError();
});
});
describe('Builder with simple options', () => {
test('GIVEN valid builder THEN returns type included', () => {
expect(getNamedBuilder().toJSON()).includes({ type: ApplicationCommandType.ChatInput });
});
test('GIVEN valid builder with options THEN does not throw error', () => {
expect(() =>
getBuilder()
.setName('example')
.setDescription('Example command')
.addBooleanOptions((boolean) =>
boolean.setName('iscool').setDescription('Are we cool or what?').setRequired(true),
)
.addChannelOptions((channel) => channel.setName('iscool').setDescription('Are we cool or what?'))
.addMentionableOptions((mentionable) =>
mentionable.setName('iscool').setDescription('Are we cool or what?'),
)
.addRoleOptions((role) => role.setName('iscool').setDescription('Are we cool or what?'))
.addUserOptions((user) => user.setName('iscool').setDescription('Are we cool or what?'))
.addIntegerOptions((integer) =>
integer
.setName('iscool')
.setDescription('Are we cool or what?')
.addChoices({ name: 'Very cool', value: 1_000 })
.addChoices([{ name: 'Even cooler', value: 2_000 }]),
)
.addNumberOptions((number) =>
number
.setName('iscool')
.setDescription('Are we cool or what?')
.addChoices({ name: 'Very cool', value: 1.5 })
.addChoices([{ name: 'Even cooler', value: 2.5 }]),
)
.addStringOptions((string) =>
string
.setName('iscool')
.setDescription('Are we cool or what?')
.addChoices({ name: 'Fancy Pants', value: 'fp_1' }, { name: 'Fancy Shoes', value: 'fs_1' })
.addChoices([{ name: 'The Whole shebang', value: 'all' }]),
)
.addIntegerOptions((integer) =>
integer.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true),
)
.addNumberOptions((number) =>
number.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true),
)
.addStringOptions((string) =>
string.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true),
)
.toJSON(),
).not.toThrowError();
});
test('GIVEN a builder with invalid autocomplete THEN does throw an error', () => {
expect(() =>
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
getNamedBuilder().addStringOptions(getStringOption().setAutocomplete('not a boolean')).toJSON(),
).toThrowError();
});
test('GIVEN a builder with both choices and autocomplete THEN does throw an error', () => {
expect(() =>
getNamedBuilder()
.addStringOptions(
getStringOption().setAutocomplete(true).addChoices({ name: 'Fancy Pants', value: 'fp_1' }),
)
.toJSON(),
).toThrowError();
expect(() =>
getNamedBuilder()
.addStringOptions(
getStringOption()
.setAutocomplete(true)
.addChoices(
{ name: 'Fancy Pants', value: 'fp_1' },
{ name: 'Fancy Shoes', value: 'fs_1' },
{ name: 'The Whole shebang', value: 'all' },
),
)
.toJSON(),
).toThrowError();
expect(() =>
getNamedBuilder()
.addStringOptions(
getStringOption().addChoices({ name: 'Fancy Pants', value: 'fp_1' }).setAutocomplete(true),
)
.toJSON(),
).toThrowError();
});
test('GIVEN a builder with valid channel options and channel_types THEN does not throw an error', () => {
expect(() =>
getNamedBuilder()
.addChannelOptions(
getChannelOption().addChannelTypes(ChannelType.GuildText).addChannelTypes([ChannelType.GuildVoice]),
)
.toJSON(),
).not.toThrowError();
expect(() => {
getNamedBuilder()
.addChannelOptions(getChannelOption().addChannelTypes(ChannelType.GuildAnnouncement, ChannelType.GuildText))
.toJSON();
}).not.toThrowError();
});
test('GIVEN a builder with valid channel options and channel_types THEN does throw an error', () => {
expect(() =>
// @ts-expect-error: Invalid channel type
getNamedBuilder().addChannelOptions(getChannelOption().addChannelTypes(100)).toJSON(),
).toThrowError();
expect(() =>
// @ts-expect-error: Invalid channel types
getNamedBuilder().addChannelOptions(getChannelOption().addChannelTypes(100, 200)).toJSON(),
).toThrowError();
});
test('GIVEN a builder with invalid number min/max options THEN does throw an error', () => {
// @ts-expect-error: Invalid max value
expect(() => getNamedBuilder().addNumberOptions(getNumberOption().setMaxValue('test')).toJSON()).toThrowError();
expect(() =>
// @ts-expect-error: Invalid max value
getNamedBuilder().addIntegerOptions(getIntegerOption().setMaxValue('test')).toJSON(),
).toThrowError();
// @ts-expect-error: Invalid min value
expect(() => getNamedBuilder().addNumberOptions(getNumberOption().setMinValue('test')).toJSON()).toThrowError();
expect(() =>
// @ts-expect-error: Invalid min value
getNamedBuilder().addIntegerOptions(getIntegerOption().setMinValue('test')).toJSON(),
).toThrowError();
expect(() => getNamedBuilder().addIntegerOptions(getIntegerOption().setMinValue(1.5)).toJSON()).toThrowError();
});
test('GIVEN a builder with valid number min/max options THEN does not throw an error', () => {
expect(() =>
getNamedBuilder().addIntegerOptions(getIntegerOption().setMinValue(1)).toJSON(),
).not.toThrowError();
expect(() =>
getNamedBuilder().addNumberOptions(getNumberOption().setMinValue(1.5)).toJSON(),
).not.toThrowError();
expect(() =>
getNamedBuilder().addIntegerOptions(getIntegerOption().setMaxValue(1)).toJSON(),
).not.toThrowError();
expect(() =>
getNamedBuilder().addNumberOptions(getNumberOption().setMaxValue(1.5)).toJSON(),
).not.toThrowError();
});
test('GIVEN an already built builder THEN does not throw an error', () => {
expect(() => getNamedBuilder().addStringOptions(getStringOption()).toJSON()).not.toThrowError();
expect(() => getNamedBuilder().addIntegerOptions(getIntegerOption()).toJSON()).not.toThrowError();
expect(() => getNamedBuilder().addNumberOptions(getNumberOption()).toJSON()).not.toThrowError();
expect(() => getNamedBuilder().addBooleanOptions(getBooleanOption()).toJSON()).not.toThrowError();
expect(() => getNamedBuilder().addUserOptions(getUserOption()).toJSON()).not.toThrowError();
expect(() => getNamedBuilder().addChannelOptions(getChannelOption()).toJSON()).not.toThrowError();
expect(() => getNamedBuilder().addRoleOptions(getRoleOption()).toJSON()).not.toThrowError();
expect(() => getNamedBuilder().addAttachmentOptions(getAttachmentOption()).toJSON()).not.toThrowError();
expect(() => getNamedBuilder().addMentionableOptions(getMentionableOption()).toJSON()).not.toThrowError();
});
test('GIVEN invalid name THEN throw error', () => {
expect(() => getBuilder().setName('TEST_COMMAND').setDescription(':3').toJSON()).toThrowError();
expect(() => getBuilder().setName('ĂĂĂĂĂĂ').setDescription(':3').toJSON()).toThrowError();
});
test('GIVEN valid names THEN does not throw error', () => {
expect(() => getBuilder().setName('hi_there').setDescription(':3')).not.toThrowError();
expect(() => getBuilder().setName('o_comandă').setDescription(':3')).not.toThrowError();
expect(() => getBuilder().setName('どうも').setDescription(':3')).not.toThrowError();
});
test('GIVEN invalid returns for builder THEN throw error', () => {
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getNamedBuilder().addBooleanOptions(true).toJSON()).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getNamedBuilder().addBooleanOptions(null).toJSON()).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getNamedBuilder().addBooleanOptions(undefined).toJSON()).toThrowError();
expect(() =>
getNamedBuilder()
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
.addBooleanOptions(() => ChatInputCommandStringOption)
.toJSON(),
).toThrowError();
expect(() =>
getNamedBuilder()
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
.addBooleanOptions(() => new Collection())
.toJSON(),
).toThrowError();
});
test('GIVEN an option that is autocompletable and has choices, THEN passing nothing to setChoices should not throw an error', () => {
expect(() =>
getNamedBuilder().addStringOptions(getStringOption().setAutocomplete(true).setChoices()).toJSON(),
).not.toThrowError();
});
test('GIVEN an option that is autocompletable, THEN setting choices should throw an error', () => {
expect(() =>
getNamedBuilder()
.addStringOptions(getStringOption().setAutocomplete(true).setChoices({ name: 'owo', value: 'uwu' }))
.toJSON(),
).toThrowError();
});
test('GIVEN an option, THEN setting choices should not throw an error', () => {
expect(() =>
getNamedBuilder()
.addStringOptions(getStringOption().setChoices({ name: 'owo', value: 'uwu' }))
.toJSON(),
).not.toThrowError();
});
test('GIVEN valid builder with NSFW, THEN does not throw error', () => {
expect(() => getNamedBuilder().setName('foo').setDescription('foo').setNSFW(true).toJSON()).not.toThrowError();
});
});
describe('Builder with subcommand (group) options', () => {
test('GIVEN builder with subcommand group THEN does not throw error', () => {
expect(() =>
getNamedBuilder()
.addSubcommandGroups((group) =>
group.setName('group').setDescription('Group us together!').addSubcommands(getSubcommand()),
)
.toJSON(),
).not.toThrowError();
});
test('GIVEN builder with subcommand THEN does not throw error', () => {
expect(() =>
getNamedBuilder()
.addSubcommands((subcommand) => subcommand.setName('boop').setDescription('Boops a fellow nerd (you)'))
.toJSON(),
).not.toThrowError();
});
test('GIVEN builder with subcommand THEN has regular ChatInput command fields', () => {
expect(() =>
getBuilder()
.setName('name')
.setDescription('description')
.addSubcommands((option) => option.setName('ye').setDescription('ye'))
.addSubcommands((option) => option.setName('no').setDescription('no'))
.setDefaultMemberPermissions(1n)
.toJSON(),
).not.toThrowError();
});
test('GIVEN builder with already built subcommand group THEN does not throw error', () => {
expect(() =>
getNamedBuilder().addSubcommandGroups(getSubcommandGroup().addSubcommands(getSubcommand())).toJSON(),
).not.toThrowError();
});
test('GIVEN builder with already built subcommand THEN does not throw error', () => {
expect(() => getNamedBuilder().addSubcommands(getSubcommand()).toJSON()).not.toThrowError();
});
test('GIVEN builder with already built subcommand with options THEN does not throw error', () => {
expect(() =>
getNamedBuilder().addSubcommands(getSubcommand().addBooleanOptions(getBooleanOption())).toJSON(),
).not.toThrowError();
});
test('GIVEN builder with a subcommand that tries to add an invalid result THEN throw error', () => {
expect(() =>
// @ts-expect-error: Checking if check works JS-side too
getNamedBuilder().addSubcommands(getSubcommand()).addIntegerOptions(getInteger()).toJSON(),
).toThrowError();
});
test('GIVEN no valid return for an addSubcommand(Group) method THEN throw error', () => {
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getNamedBuilder().addSubcommands(getSubcommandGroup()).toJSON()).toThrowError();
});
});
describe('Subcommand group builder', () => {
test('GIVEN no valid subcommand THEN throw error', () => {
expect(() => getSubcommandGroup().addSubcommands().toJSON()).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getSubcommandGroup().addSubcommands(getSubcommandGroup()).toJSON()).toThrowError();
});
test('GIVEN a valid subcommand THEN does not throw an error', () => {
expect(() =>
getSubcommandGroup()
.addSubcommands((sub) => sub.setName('sub').setDescription('Testing 123'))
.toJSON(),
).not.toThrowError();
});
});
describe('Subcommand builder', () => {
test('GIVEN a valid subcommand with options THEN does not throw error', () => {
expect(() => getSubcommand().addBooleanOptions(getBooleanOption()).toJSON()).not.toThrowError();
});
});
describe('ChatInput command localizations', () => {
const expectedSingleLocale = { 'en-US': 'foobar' };
const expectedMultipleLocales = {
...expectedSingleLocale,
bg: 'test',
};
test('GIVEN valid name localizations THEN does not throw error', () => {
expect(() => getBuilder().setNameLocalization('en-US', 'foobar')).not.toThrowError();
expect(() => getBuilder().setNameLocalizations({ 'en-US': 'foobar' })).not.toThrowError();
});
test('GIVEN invalid name localizations THEN does throw error', () => {
// @ts-expect-error: Invalid localization
expect(() => getNamedBuilder().setNameLocalization('en-U', 'foobar').toJSON()).toThrowError();
// @ts-expect-error: Invalid localization
expect(() => getNamedBuilder().setNameLocalizations({ 'en-U': 'foobar' }).toJSON()).toThrowError();
});
test('GIVEN valid name localizations THEN valid data is stored', () => {
expect(getNamedBuilder().setNameLocalization('en-US', 'foobar').toJSON().name_localizations).toEqual(
expectedSingleLocale,
);
expect(
getNamedBuilder().setNameLocalizations({ 'en-US': 'foobar', bg: 'test' }).toJSON().name_localizations,
).toEqual(expectedMultipleLocales);
expect(getNamedBuilder().clearNameLocalizations().toJSON().name_localizations).toBeUndefined();
expect(getNamedBuilder().clearNameLocalization('en-US').toJSON().name_localizations).toEqual({
'en-US': undefined,
});
});
test('GIVEN valid description localizations THEN does not throw error', () => {
expect(() => getNamedBuilder().setDescriptionLocalization('en-US', 'foobar').toJSON()).not.toThrowError();
expect(() => getNamedBuilder().setDescriptionLocalizations({ 'en-US': 'foobar' }).toJSON()).not.toThrowError();
});
test('GIVEN invalid description localizations THEN does throw error', () => {
// @ts-expect-error: Invalid localization description
expect(() => getNamedBuilder().setDescriptionLocalization('en-U', 'foobar').toJSON()).toThrowError();
// @ts-expect-error: Invalid localization description
expect(() => getNamedBuilder().setDescriptionLocalizations({ 'en-U': 'foobar' }).toJSON()).toThrowError();
});
test('GIVEN valid description localizations THEN valid data is stored', () => {
expect(
getNamedBuilder().setDescriptionLocalization('en-US', 'foobar').toJSON(false).description_localizations,
).toEqual(expectedSingleLocale);
expect(
getNamedBuilder().setDescriptionLocalizations({ 'en-US': 'foobar', bg: 'test' }).toJSON(false)
.description_localizations,
).toEqual(expectedMultipleLocales);
expect(
getNamedBuilder().clearDescriptionLocalizations().toJSON(false).description_localizations,
).toBeUndefined();
expect(getNamedBuilder().clearDescriptionLocalization('en-US').toJSON(false).description_localizations).toEqual(
{
'en-US': undefined,
},
);
});
});
describe('permissions', () => {
test('GIVEN valid permission string THEN does not throw error', () => {
expect(() => getNamedBuilder().setDefaultMemberPermissions('1')).not.toThrowError();
});
test('GIVEN valid permission bitfield THEN does not throw error', () => {
expect(() =>
getNamedBuilder().setDefaultMemberPermissions(
PermissionFlagsBits.AddReactions | PermissionFlagsBits.AttachFiles,
),
).not.toThrowError();
});
test('GIVEN null permissions THEN does not throw error', () => {
expect(() => getNamedBuilder().clearDefaultMemberPermissions()).not.toThrowError();
});
test('GIVEN invalid inputs THEN does throw error', () => {
expect(() => getNamedBuilder().setDefaultMemberPermissions('1.1').toJSON()).toThrowError();
expect(() => getNamedBuilder().setDefaultMemberPermissions(1.1).toJSON()).toThrowError();
});
test('GIVEN valid permission with options THEN does not throw error', () => {
expect(() =>
getNamedBuilder().addBooleanOptions(getBooleanOption()).setDefaultMemberPermissions('1').toJSON(),
).not.toThrowError();
expect(() => getNamedBuilder().addChannelOptions(getChannelOption())).not.toThrowError();
});
});
describe('contexts', () => {
test('GIVEN a builder with valid contexts THEN does not throw an error', () => {
expect(() =>
getNamedBuilder().setContexts([InteractionContextType.Guild, InteractionContextType.BotDM]).toJSON(),
).not.toThrowError();
expect(() =>
getNamedBuilder().setContexts(InteractionContextType.Guild, InteractionContextType.BotDM).toJSON(),
).not.toThrowError();
});
test('GIVEN a builder with invalid contexts THEN does throw an error', () => {
// @ts-expect-error: Invalid contexts
expect(() => getNamedBuilder().setContexts(999).toJSON()).toThrowError();
// @ts-expect-error: Invalid contexts
expect(() => getNamedBuilder().setContexts([999, 998]).toJSON()).toThrowError();
});
});
describe('integration types', () => {
test('GIVEN a builder with valid integraton types THEN does not throw an error', () => {
expect(() =>
getNamedBuilder()
.setIntegrationTypes([ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall])
.toJSON(),
).not.toThrowError();
expect(() =>
getNamedBuilder()
.setIntegrationTypes(ApplicationIntegrationType.GuildInstall, ApplicationIntegrationType.UserInstall)
.toJSON(),
).not.toThrowError();
});
test('GIVEN a builder with invalid integration types THEN does throw an error', () => {
// @ts-expect-error: Invalid integration types
expect(() => getNamedBuilder().setIntegrationTypes(999).toJSON()).toThrowError();
// @ts-expect-error: Invalid integration types
expect(() => getNamedBuilder().setIntegrationTypes([999, 998]).toJSON()).toThrowError();
});
});
});
});

View File

@@ -1,31 +1,74 @@
import {
ApplicationCommandType,
ApplicationIntegrationType,
InteractionContextType,
PermissionFlagsBits,
} from 'discord-api-types/v10';
import { ApplicationIntegrationType, InteractionContextType, PermissionFlagsBits } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { MessageContextCommandBuilder } from '../../src/index.js';
import { ContextMenuCommandAssertions, ContextMenuCommandBuilder } from '../../src/index.js';
const getBuilder = () => new MessageContextCommandBuilder();
const getBuilder = () => new ContextMenuCommandBuilder();
describe('Context Menu Commands', () => {
describe('Assertions tests', () => {
test('GIVEN valid name THEN does not throw error', () => {
expect(() => ContextMenuCommandAssertions.validateName('ping')).not.toThrowError();
});
test('GIVEN invalid name THEN throw error', () => {
expect(() => ContextMenuCommandAssertions.validateName(null)).toThrowError();
// Too short of a name
expect(() => ContextMenuCommandAssertions.validateName('')).toThrowError();
// Invalid characters used
expect(() => ContextMenuCommandAssertions.validateName('ABC123$%^&')).toThrowError();
// Too long of a name
expect(() =>
ContextMenuCommandAssertions.validateName('qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm'),
).toThrowError();
});
test('GIVEN valid type THEN does not throw error', () => {
expect(() => ContextMenuCommandAssertions.validateType(3)).not.toThrowError();
});
test('GIVEN invalid type THEN throw error', () => {
expect(() => ContextMenuCommandAssertions.validateType(null)).toThrowError();
// Out of range
expect(() => ContextMenuCommandAssertions.validateType(1)).toThrowError();
});
test('GIVEN valid required parameters THEN does not throw error', () => {
expect(() => ContextMenuCommandAssertions.validateRequiredParameters('owo', 2)).not.toThrowError();
});
test('GIVEN valid default_permission THEN does not throw error', () => {
expect(() => ContextMenuCommandAssertions.validateDefaultPermission(true)).not.toThrowError();
});
test('GIVEN invalid default_permission THEN throw error', () => {
expect(() => ContextMenuCommandAssertions.validateDefaultPermission(null)).toThrowError();
});
});
describe('ContextMenuCommandBuilder', () => {
describe('Builder tests', () => {
test('GIVEN empty builder THEN throw error when calling toJSON', () => {
expect(() => getBuilder().toJSON()).toThrowError();
});
test('GIVEN invalid name THEN throw error', () => {
expect(() => getBuilder().setName('$$$').toJSON()).toThrowError();
test('GIVEN valid builder THEN does not throw error', () => {
expect(() => getBuilder().setName('example').setType(3).toJSON()).not.toThrowError();
});
expect(() => getBuilder().setName(' ').toJSON()).toThrowError();
test('GIVEN invalid name THEN throw error', () => {
expect(() => getBuilder().setName('$$$')).toThrowError();
expect(() => getBuilder().setName(' ')).toThrowError();
});
test('GIVEN valid names THEN does not throw error', () => {
expect(() => getBuilder().setName('hi_there').toJSON()).not.toThrowError();
expect(() => getBuilder().setName('hi_there')).not.toThrowError();
expect(() => getBuilder().setName('A COMMAND').toJSON()).not.toThrowError();
expect(() => getBuilder().setName('A COMMAND')).not.toThrowError();
// Translation: a_command
expect(() => getBuilder().setName('o_comandă')).not.toThrowError();
@@ -33,6 +76,20 @@ describe('Context Menu Commands', () => {
// Translation: thx (according to GTranslate)
expect(() => getBuilder().setName('どうも')).not.toThrowError();
});
test('GIVEN valid types THEN does not throw error', () => {
expect(() => getBuilder().setType(2)).not.toThrowError();
expect(() => getBuilder().setType(3)).not.toThrowError();
});
test('GIVEN valid builder with defaultPermission false THEN does not throw error', () => {
expect(() => getBuilder().setName('foo').setDefaultPermission(false)).not.toThrowError();
});
test('GIVEN valid builder with dmPermission false THEN does not throw error', () => {
expect(() => getBuilder().setName('foo').setDMPermission(false)).not.toThrowError();
});
});
describe('Context menu command localizations', () => {
@@ -49,22 +106,19 @@ describe('Context Menu Commands', () => {
test('GIVEN invalid name localizations THEN does throw error', () => {
// @ts-expect-error: Invalid localization
expect(() => getBuilder().setNameLocalization('en-U', 'foobar').toJSON()).toThrowError();
expect(() => getBuilder().setNameLocalization('en-U', 'foobar')).toThrowError();
// @ts-expect-error: Invalid localization
expect(() => getBuilder().setNameLocalizations({ 'en-U': 'foobar' }).toJSON()).toThrowError();
expect(() => getBuilder().setNameLocalizations({ 'en-U': 'foobar' })).toThrowError();
});
test('GIVEN valid name localizations THEN valid data is stored', () => {
expect(getBuilder().setName('hi').setNameLocalization('en-US', 'foobar').toJSON().name_localizations).toEqual(
expectedSingleLocale,
expect(getBuilder().setNameLocalization('en-US', 'foobar').name_localizations).toEqual(expectedSingleLocale);
expect(getBuilder().setNameLocalizations({ 'en-US': 'foobar', bg: 'test' }).name_localizations).toEqual(
expectedMultipleLocales,
);
expect(
getBuilder().setName('hi').setNameLocalizations({ 'en-US': 'foobar', bg: 'test' }).toJSON()
.name_localizations,
).toEqual(expectedMultipleLocales);
expect(getBuilder().setName('hi').clearNameLocalizations().toJSON().name_localizations).toBeUndefined();
expect(getBuilder().setName('hi').clearNameLocalization('en-US').toJSON().name_localizations).toEqual({
'en-US': undefined,
expect(getBuilder().setNameLocalizations(null).name_localizations).toBeNull();
expect(getBuilder().setNameLocalization('en-US', null).name_localizations).toEqual({
'en-US': null,
});
});
});
@@ -80,10 +134,14 @@ describe('Context Menu Commands', () => {
).not.toThrowError();
});
test('GIVEN invalid inputs THEN does throw error', () => {
expect(() => getBuilder().setName('hi').setDefaultMemberPermissions('1.1').toJSON()).toThrowError();
test('GIVEN null permissions THEN does not throw error', () => {
expect(() => getBuilder().setDefaultMemberPermissions(null)).not.toThrowError();
});
expect(() => getBuilder().setName('hi').setDefaultMemberPermissions(1.1).toJSON()).toThrowError();
test('GIVEN invalid inputs THEN does throw error', () => {
expect(() => getBuilder().setDefaultMemberPermissions('1.1')).toThrowError();
expect(() => getBuilder().setDefaultMemberPermissions(1.1)).toThrowError();
});
});
@@ -100,15 +158,15 @@ describe('Context Menu Commands', () => {
test('GIVEN a builder with invalid contexts THEN does throw an error', () => {
// @ts-expect-error: Invalid contexts
expect(() => getBuilder().setName('hi').setContexts(999).toJSON()).toThrowError();
expect(() => getBuilder().setContexts(999)).toThrowError();
// @ts-expect-error: Invalid contexts
expect(() => getBuilder().setName('hi').setContexts([999, 998]).toJSON()).toThrowError();
expect(() => getBuilder().setContexts([999, 998])).toThrowError();
});
});
describe('integration types', () => {
test('GIVEN a builder with valid integraton types THEN does not throw an error', () => {
test('GIVEN a builder with valid integration types THEN does not throw an error', () => {
expect(() =>
getBuilder().setIntegrationTypes([
ApplicationIntegrationType.GuildInstall,
@@ -126,10 +184,10 @@ describe('Context Menu Commands', () => {
test('GIVEN a builder with invalid integration types THEN does throw an error', () => {
// @ts-expect-error: Invalid integration types
expect(() => getBuilder().setName('hi').setIntegrationTypes(999).toJSON()).toThrowError();
expect(() => getBuilder().setIntegrationTypes(999)).toThrowError();
// @ts-expect-error: Invalid integration types
expect(() => getBuilder().setName('hi').setIntegrationTypes([999, 998]).toJSON()).toThrowError();
expect(() => getBuilder().setIntegrationTypes([999, 998])).toThrowError();
});
});
});

View File

@@ -13,32 +13,32 @@ import {
} from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import {
ChatInputCommandAttachmentOption,
ChatInputCommandBooleanOption,
ChatInputCommandChannelOption,
ChatInputCommandIntegerOption,
ChatInputCommandMentionableOption,
ChatInputCommandNumberOption,
ChatInputCommandRoleOption,
ChatInputCommandStringOption,
ChatInputCommandUserOption,
SlashCommandAttachmentOption,
SlashCommandBooleanOption,
SlashCommandChannelOption,
SlashCommandIntegerOption,
SlashCommandMentionableOption,
SlashCommandNumberOption,
SlashCommandRoleOption,
SlashCommandStringOption,
SlashCommandUserOption,
} from '../../../src/index.js';
const getBooleanOption = () =>
new ChatInputCommandBooleanOption().setName('owo').setDescription('Testing 123').setRequired(true);
new SlashCommandBooleanOption().setName('owo').setDescription('Testing 123').setRequired(true);
const getChannelOption = () =>
new ChatInputCommandChannelOption()
new SlashCommandChannelOption()
.setName('owo')
.setDescription('Testing 123')
.setRequired(true)
.addChannelTypes(ChannelType.GuildText);
const getStringOption = () =>
new ChatInputCommandStringOption().setName('owo').setDescription('Testing 123').setRequired(true);
new SlashCommandStringOption().setName('owo').setDescription('Testing 123').setRequired(true);
const getIntegerOption = () =>
new ChatInputCommandIntegerOption()
new SlashCommandIntegerOption()
.setName('owo')
.setDescription('Testing 123')
.setRequired(true)
@@ -46,24 +46,22 @@ const getIntegerOption = () =>
.setMaxValue(10);
const getNumberOption = () =>
new ChatInputCommandNumberOption()
new SlashCommandNumberOption()
.setName('owo')
.setDescription('Testing 123')
.setRequired(true)
.setMinValue(-1.23)
.setMaxValue(10);
const getUserOption = () =>
new ChatInputCommandUserOption().setName('owo').setDescription('Testing 123').setRequired(true);
const getUserOption = () => new SlashCommandUserOption().setName('owo').setDescription('Testing 123').setRequired(true);
const getRoleOption = () =>
new ChatInputCommandRoleOption().setName('owo').setDescription('Testing 123').setRequired(true);
const getRoleOption = () => new SlashCommandRoleOption().setName('owo').setDescription('Testing 123').setRequired(true);
const getMentionableOption = () =>
new ChatInputCommandMentionableOption().setName('owo').setDescription('Testing 123').setRequired(true);
new SlashCommandMentionableOption().setName('owo').setDescription('Testing 123').setRequired(true);
const getAttachmentOption = () =>
new ChatInputCommandAttachmentOption().setName('attachment').setDescription('attachment').setRequired(true);
new SlashCommandAttachmentOption().setName('attachment').setDescription('attachment').setRequired(true);
describe('Application Command toJSON() results', () => {
test('GIVEN a boolean option THEN calling toJSON should return a valid JSON', () => {
@@ -103,6 +101,7 @@ describe('Application Command toJSON() results', () => {
max_value: 10,
min_value: -1,
autocomplete: true,
// TODO
choices: [],
});

View File

@@ -0,0 +1,593 @@
import {
ApplicationCommandType,
ApplicationIntegrationType,
ChannelType,
InteractionContextType,
PermissionFlagsBits,
type APIApplicationCommandOptionChoice,
} from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import {
SlashCommandAssertions,
SlashCommandBooleanOption,
SlashCommandBuilder,
SlashCommandChannelOption,
SlashCommandIntegerOption,
SlashCommandMentionableOption,
SlashCommandNumberOption,
SlashCommandRoleOption,
SlashCommandAttachmentOption,
SlashCommandStringOption,
SlashCommandSubcommandBuilder,
SlashCommandSubcommandGroupBuilder,
SlashCommandUserOption,
} from '../../../src/index.js';
const largeArray = Array.from({ length: 26 }, () => 1 as unknown as APIApplicationCommandOptionChoice);
const getBuilder = () => new SlashCommandBuilder();
const getNamedBuilder = () => getBuilder().setName('example').setDescription('Example command');
const getStringOption = () => new SlashCommandStringOption().setName('owo').setDescription('Testing 123');
const getIntegerOption = () => new SlashCommandIntegerOption().setName('owo').setDescription('Testing 123');
const getNumberOption = () => new SlashCommandNumberOption().setName('owo').setDescription('Testing 123');
const getBooleanOption = () => new SlashCommandBooleanOption().setName('owo').setDescription('Testing 123');
const getUserOption = () => new SlashCommandUserOption().setName('owo').setDescription('Testing 123');
const getChannelOption = () => new SlashCommandChannelOption().setName('owo').setDescription('Testing 123');
const getRoleOption = () => new SlashCommandRoleOption().setName('owo').setDescription('Testing 123');
const getAttachmentOption = () => new SlashCommandAttachmentOption().setName('owo').setDescription('Testing 123');
const getMentionableOption = () => new SlashCommandMentionableOption().setName('owo').setDescription('Testing 123');
const getSubcommandGroup = () => new SlashCommandSubcommandGroupBuilder().setName('owo').setDescription('Testing 123');
const getSubcommand = () => new SlashCommandSubcommandBuilder().setName('owo').setDescription('Testing 123');
class Collection {
public readonly [Symbol.toStringTag] = 'Map';
}
describe('Slash Commands', () => {
describe('Assertions tests', () => {
test('GIVEN valid name THEN does not throw error', () => {
expect(() => SlashCommandAssertions.validateName('ping')).not.toThrowError();
expect(() => SlashCommandAssertions.validateName('hello-world_command')).not.toThrowError();
expect(() => SlashCommandAssertions.validateName('aˇ㐆1٢〣²अก')).not.toThrowError();
});
test('GIVEN invalid name THEN throw error', () => {
expect(() => SlashCommandAssertions.validateName(null)).toThrowError();
// Too short of a name
expect(() => SlashCommandAssertions.validateName('')).toThrowError();
// Invalid characters used
expect(() => SlashCommandAssertions.validateName('ABC')).toThrowError();
expect(() => SlashCommandAssertions.validateName('ABC123$%^&')).toThrowError();
expect(() => SlashCommandAssertions.validateName('help ping')).toThrowError();
expect(() => SlashCommandAssertions.validateName('🦦')).toThrowError();
// Too long of a name
expect(() =>
SlashCommandAssertions.validateName('qwertyuiopasdfghjklzxcvbnmqwertyuiopasdfghjklzxcvbnm'),
).toThrowError();
});
test('GIVEN valid description THEN does not throw error', () => {
expect(() => SlashCommandAssertions.validateDescription('This is an OwO moment fur sure!~')).not.toThrowError();
});
test('GIVEN invalid description THEN throw error', () => {
expect(() => SlashCommandAssertions.validateDescription(null)).toThrowError();
// Too short of a description
expect(() => SlashCommandAssertions.validateDescription('')).toThrowError();
// Too long of a description
expect(() =>
SlashCommandAssertions.validateDescription(
'Lorem ipsum dolor sit amet, consectetur adipisicing elit. Magnam autem libero expedita vitae accusamus nostrum ipsam tempore repudiandae deserunt ipsum facilis, velit fugiat facere accusantium, explicabo corporis aliquam non quos.',
),
).toThrowError();
});
test('GIVEN valid default_permission THEN does not throw error', () => {
expect(() => SlashCommandAssertions.validateDefaultPermission(true)).not.toThrowError();
});
test('GIVEN invalid default_permission THEN throw error', () => {
expect(() => SlashCommandAssertions.validateDefaultPermission(null)).toThrowError();
});
test('GIVEN valid array of options or choices THEN does not throw error', () => {
expect(() => SlashCommandAssertions.validateMaxOptionsLength([])).not.toThrowError();
expect(() => SlashCommandAssertions.validateChoicesLength(25)).not.toThrowError();
expect(() => SlashCommandAssertions.validateChoicesLength(25, [])).not.toThrowError();
});
test('GIVEN invalid options or choices THEN throw error', () => {
expect(() => SlashCommandAssertions.validateMaxOptionsLength(null)).toThrowError();
// Given an array that's too big
expect(() => SlashCommandAssertions.validateMaxOptionsLength(largeArray)).toThrowError();
expect(() => SlashCommandAssertions.validateChoicesLength(1, largeArray)).toThrowError();
});
test('GIVEN valid required parameters THEN does not throw error', () => {
expect(() =>
SlashCommandAssertions.validateRequiredParameters(
'owo',
'My fancy command that totally exists, to test assertions',
[],
),
).not.toThrowError();
});
});
describe('SlashCommandBuilder', () => {
describe('Builder with no options', () => {
test('GIVEN empty builder THEN throw error when calling toJSON', () => {
expect(() => getBuilder().toJSON()).toThrowError();
});
test('GIVEN valid builder THEN does not throw error', () => {
expect(() => getBuilder().setName('example').setDescription('Example command').toJSON()).not.toThrowError();
});
});
describe('Builder with simple options', () => {
test('GIVEN valid builder THEN returns type included', () => {
expect(getNamedBuilder().toJSON()).includes({ type: ApplicationCommandType.ChatInput });
});
test('GIVEN valid builder with options THEN does not throw error', () => {
expect(() =>
getBuilder()
.setName('example')
.setDescription('Example command')
.setDMPermission(false)
.addBooleanOption((boolean) =>
boolean.setName('iscool').setDescription('Are we cool or what?').setRequired(true),
)
.addChannelOption((channel) => channel.setName('iscool').setDescription('Are we cool or what?'))
.addMentionableOption((mentionable) => mentionable.setName('iscool').setDescription('Are we cool or what?'))
.addRoleOption((role) => role.setName('iscool').setDescription('Are we cool or what?'))
.addUserOption((user) => user.setName('iscool').setDescription('Are we cool or what?'))
.addIntegerOption((integer) =>
integer
.setName('iscool')
.setDescription('Are we cool or what?')
.addChoices({ name: 'Very cool', value: 1_000 })
.addChoices([{ name: 'Even cooler', value: 2_000 }]),
)
.addNumberOption((number) =>
number
.setName('iscool')
.setDescription('Are we cool or what?')
.addChoices({ name: 'Very cool', value: 1.5 })
.addChoices([{ name: 'Even cooler', value: 2.5 }]),
)
.addStringOption((string) =>
string
.setName('iscool')
.setDescription('Are we cool or what?')
.addChoices({ name: 'Fancy Pants', value: 'fp_1' }, { name: 'Fancy Shoes', value: 'fs_1' })
.addChoices([{ name: 'The Whole shebang', value: 'all' }]),
)
.addIntegerOption((integer) =>
integer.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true),
)
.addNumberOption((number) =>
number.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true),
)
.addStringOption((string) =>
string.setName('iscool').setDescription('Are we cool or what?').setAutocomplete(true),
)
.toJSON(),
).not.toThrowError();
});
test('GIVEN a builder with invalid autocomplete THEN does throw an error', () => {
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addStringOption(getStringOption().setAutocomplete('not a boolean'))).toThrowError();
});
test('GIVEN a builder with both choices and autocomplete THEN does throw an error', () => {
expect(() =>
getBuilder().addStringOption(
getStringOption().setAutocomplete(true).addChoices({ name: 'Fancy Pants', value: 'fp_1' }),
),
).toThrowError();
expect(() =>
getBuilder().addStringOption(
getStringOption()
.setAutocomplete(true)
.addChoices(
{ name: 'Fancy Pants', value: 'fp_1' },
{ name: 'Fancy Shoes', value: 'fs_1' },
{ name: 'The Whole shebang', value: 'all' },
),
),
).toThrowError();
expect(() =>
getBuilder().addStringOption(
getStringOption().addChoices({ name: 'Fancy Pants', value: 'fp_1' }).setAutocomplete(true),
),
).toThrowError();
expect(() => {
const option = getStringOption();
Reflect.set(option, 'autocomplete', true);
Reflect.set(option, 'choices', [{ name: 'Fancy Pants', value: 'fp_1' }]);
return option.toJSON();
}).toThrowError();
expect(() => {
const option = getNumberOption();
Reflect.set(option, 'autocomplete', true);
Reflect.set(option, 'choices', [{ name: 'Fancy Pants', value: 'fp_1' }]);
return option.toJSON();
}).toThrowError();
expect(() => {
const option = getIntegerOption();
Reflect.set(option, 'autocomplete', true);
Reflect.set(option, 'choices', [{ name: 'Fancy Pants', value: 'fp_1' }]);
return option.toJSON();
}).toThrowError();
});
test('GIVEN a builder with valid channel options and channel_types THEN does not throw an error', () => {
expect(() =>
getBuilder().addChannelOption(
getChannelOption().addChannelTypes(ChannelType.GuildText).addChannelTypes([ChannelType.GuildVoice]),
),
).not.toThrowError();
expect(() => {
getBuilder().addChannelOption(
getChannelOption().addChannelTypes(ChannelType.GuildAnnouncement, ChannelType.GuildText),
);
}).not.toThrowError();
});
test('GIVEN a builder with valid channel options and channel_types THEN does throw an error', () => {
// @ts-expect-error: Invalid channel type
expect(() => getBuilder().addChannelOption(getChannelOption().addChannelTypes(100))).toThrowError();
// @ts-expect-error: Invalid channel types
expect(() => getBuilder().addChannelOption(getChannelOption().addChannelTypes(100, 200))).toThrowError();
});
test('GIVEN a builder with invalid number min/max options THEN does throw an error', () => {
// @ts-expect-error: Invalid max value
expect(() => getBuilder().addNumberOption(getNumberOption().setMaxValue('test'))).toThrowError();
// @ts-expect-error: Invalid max value
expect(() => getBuilder().addIntegerOption(getIntegerOption().setMaxValue('test'))).toThrowError();
// @ts-expect-error: Invalid min value
expect(() => getBuilder().addNumberOption(getNumberOption().setMinValue('test'))).toThrowError();
// @ts-expect-error: Invalid min value
expect(() => getBuilder().addIntegerOption(getIntegerOption().setMinValue('test'))).toThrowError();
expect(() => getBuilder().addIntegerOption(getIntegerOption().setMinValue(1.5))).toThrowError();
});
test('GIVEN a builder with valid number min/max options THEN does not throw an error', () => {
expect(() => getBuilder().addIntegerOption(getIntegerOption().setMinValue(1))).not.toThrowError();
expect(() => getBuilder().addNumberOption(getNumberOption().setMinValue(1.5))).not.toThrowError();
expect(() => getBuilder().addIntegerOption(getIntegerOption().setMaxValue(1))).not.toThrowError();
expect(() => getBuilder().addNumberOption(getNumberOption().setMaxValue(1.5))).not.toThrowError();
});
test('GIVEN an already built builder THEN does not throw an error', () => {
expect(() => getBuilder().addStringOption(getStringOption())).not.toThrowError();
expect(() => getBuilder().addIntegerOption(getIntegerOption())).not.toThrowError();
expect(() => getBuilder().addNumberOption(getNumberOption())).not.toThrowError();
expect(() => getBuilder().addBooleanOption(getBooleanOption())).not.toThrowError();
expect(() => getBuilder().addUserOption(getUserOption())).not.toThrowError();
expect(() => getBuilder().addChannelOption(getChannelOption())).not.toThrowError();
expect(() => getBuilder().addRoleOption(getRoleOption())).not.toThrowError();
expect(() => getBuilder().addAttachmentOption(getAttachmentOption())).not.toThrowError();
expect(() => getBuilder().addMentionableOption(getMentionableOption())).not.toThrowError();
});
test('GIVEN no valid return for an addOption method THEN throw error', () => {
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addBooleanOption()).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addBooleanOption(getRoleOption())).toThrowError();
});
test('GIVEN invalid name THEN throw error', () => {
expect(() => getBuilder().setName('TEST_COMMAND')).toThrowError();
expect(() => getBuilder().setName('ĂĂĂĂĂĂ')).toThrowError();
});
test('GIVEN valid names THEN does not throw error', () => {
expect(() => getBuilder().setName('hi_there')).not.toThrowError();
// Translation: a_command
expect(() => getBuilder().setName('o_comandă')).not.toThrowError();
// Translation: thx (according to GTranslate)
expect(() => getBuilder().setName('どうも')).not.toThrowError();
});
test('GIVEN invalid returns for builder THEN throw error', () => {
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addBooleanOption(true)).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addBooleanOption(null)).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addBooleanOption(undefined)).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addBooleanOption(() => SlashCommandStringOption)).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addBooleanOption(() => new Collection())).toThrowError();
});
test('GIVEN valid builder with defaultPermission false THEN does not throw error', () => {
expect(() => getBuilder().setName('foo').setDescription('foo').setDefaultPermission(false)).not.toThrowError();
});
test('GIVEN an option that is autocompletable and has choices, THEN passing nothing to setChoices should not throw an error', () => {
expect(() =>
getBuilder().addStringOption(getStringOption().setAutocomplete(true).setChoices()),
).not.toThrowError();
});
test('GIVEN an option that is autocompletable, THEN setting choices should throw an error', () => {
expect(() =>
getBuilder().addStringOption(
getStringOption().setAutocomplete(true).setChoices({ name: 'owo', value: 'uwu' }),
),
).toThrowError();
});
test('GIVEN an option, THEN setting choices should not throw an error', () => {
expect(() =>
getBuilder().addStringOption(getStringOption().setChoices({ name: 'owo', value: 'uwu' })),
).not.toThrowError();
});
test('GIVEN valid builder with NSFW, THEN does not throw error', () => {
expect(() => getBuilder().setName('foo').setDescription('foo').setNSFW(true)).not.toThrowError();
});
});
describe('Builder with subcommand (group) options', () => {
test('GIVEN builder with subcommand group THEN does not throw error', () => {
expect(() =>
getNamedBuilder().addSubcommandGroup((group) => group.setName('group').setDescription('Group us together!')),
).not.toThrowError();
});
test('GIVEN builder with subcommand THEN does not throw error', () => {
expect(() =>
getNamedBuilder().addSubcommand((subcommand) =>
subcommand.setName('boop').setDescription('Boops a fellow nerd (you)'),
),
).not.toThrowError();
});
test('GIVEN builder with subcommand THEN has regular slash command fields', () => {
expect(() =>
getBuilder()
.setName('name')
.setDescription('description')
.addSubcommand((option) => option.setName('ye').setDescription('ye'))
.addSubcommand((option) => option.setName('no').setDescription('no'))
.setDMPermission(false)
.setDefaultMemberPermissions(1n),
).not.toThrowError();
});
test('GIVEN builder with already built subcommand group THEN does not throw error', () => {
expect(() => getNamedBuilder().addSubcommandGroup(getSubcommandGroup())).not.toThrowError();
});
test('GIVEN builder with already built subcommand THEN does not throw error', () => {
expect(() => getNamedBuilder().addSubcommand(getSubcommand())).not.toThrowError();
});
test('GIVEN builder with already built subcommand with options THEN does not throw error', () => {
expect(() =>
getNamedBuilder().addSubcommand(getSubcommand().addBooleanOption(getBooleanOption())),
).not.toThrowError();
});
test('GIVEN builder with a subcommand that tries to add an invalid result THEN throw error', () => {
expect(() =>
// @ts-expect-error: Checking if check works JS-side too
getNamedBuilder().addSubcommand(getSubcommand()).addInteger(getInteger()),
).toThrowError();
});
test('GIVEN no valid return for an addSubcommand(Group) method THEN throw error', () => {
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addSubcommandGroup()).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addSubcommand()).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getBuilder().addSubcommand(getSubcommandGroup())).toThrowError();
});
});
describe('Subcommand group builder', () => {
test('GIVEN no valid subcommand THEN throw error', () => {
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getSubcommandGroup().addSubcommand()).toThrowError();
// @ts-expect-error: Checking if not providing anything, or an invalid return type causes an error
expect(() => getSubcommandGroup().addSubcommand(getSubcommandGroup())).toThrowError();
});
test('GIVEN a valid subcommand THEN does not throw an error', () => {
expect(() =>
getSubcommandGroup()
.addSubcommand((sub) => sub.setName('sub').setDescription('Testing 123'))
.toJSON(),
).not.toThrowError();
});
});
describe('Subcommand builder', () => {
test('GIVEN a valid subcommand with options THEN does not throw error', () => {
expect(() => getSubcommand().addBooleanOption(getBooleanOption()).toJSON()).not.toThrowError();
});
});
describe('Slash command localizations', () => {
const expectedSingleLocale = { 'en-US': 'foobar' };
const expectedMultipleLocales = {
...expectedSingleLocale,
bg: 'test',
};
test('GIVEN valid name localizations THEN does not throw error', () => {
expect(() => getBuilder().setNameLocalization('en-US', 'foobar')).not.toThrowError();
expect(() => getBuilder().setNameLocalizations({ 'en-US': 'foobar' })).not.toThrowError();
});
test('GIVEN invalid name localizations THEN does throw error', () => {
// @ts-expect-error: Invalid localization
expect(() => getBuilder().setNameLocalization('en-U', 'foobar')).toThrowError();
// @ts-expect-error: Invalid localization
expect(() => getBuilder().setNameLocalizations({ 'en-U': 'foobar' })).toThrowError();
});
test('GIVEN valid name localizations THEN valid data is stored', () => {
expect(getBuilder().setNameLocalization('en-US', 'foobar').name_localizations).toEqual(expectedSingleLocale);
expect(getBuilder().setNameLocalizations({ 'en-US': 'foobar', bg: 'test' }).name_localizations).toEqual(
expectedMultipleLocales,
);
expect(getBuilder().setNameLocalizations(null).name_localizations).toBeNull();
expect(getBuilder().setNameLocalization('en-US', null).name_localizations).toEqual({
'en-US': null,
});
});
test('GIVEN valid description localizations THEN does not throw error', () => {
expect(() => getBuilder().setDescriptionLocalization('en-US', 'foobar')).not.toThrowError();
expect(() => getBuilder().setDescriptionLocalizations({ 'en-US': 'foobar' })).not.toThrowError();
});
test('GIVEN invalid description localizations THEN does throw error', () => {
// @ts-expect-error: Invalid localization description
expect(() => getBuilder().setDescriptionLocalization('en-U', 'foobar')).toThrowError();
// @ts-expect-error: Invalid localization description
expect(() => getBuilder().setDescriptionLocalizations({ 'en-U': 'foobar' })).toThrowError();
});
test('GIVEN valid description localizations THEN valid data is stored', () => {
expect(getBuilder().setDescriptionLocalization('en-US', 'foobar').description_localizations).toEqual(
expectedSingleLocale,
);
expect(
getBuilder().setDescriptionLocalizations({ 'en-US': 'foobar', bg: 'test' }).description_localizations,
).toEqual(expectedMultipleLocales);
expect(getBuilder().setDescriptionLocalizations(null).description_localizations).toBeNull();
expect(getBuilder().setDescriptionLocalization('en-US', null).description_localizations).toEqual({
'en-US': null,
});
});
});
describe('permissions', () => {
test('GIVEN valid permission string THEN does not throw error', () => {
expect(() => getBuilder().setDefaultMemberPermissions('1')).not.toThrowError();
});
test('GIVEN valid permission bitfield THEN does not throw error', () => {
expect(() =>
getBuilder().setDefaultMemberPermissions(PermissionFlagsBits.AddReactions | PermissionFlagsBits.AttachFiles),
).not.toThrowError();
});
test('GIVEN null permissions THEN does not throw error', () => {
expect(() => getBuilder().setDefaultMemberPermissions(null)).not.toThrowError();
});
test('GIVEN invalid inputs THEN does throw error', () => {
expect(() => getBuilder().setDefaultMemberPermissions('1.1')).toThrowError();
expect(() => getBuilder().setDefaultMemberPermissions(1.1)).toThrowError();
});
test('GIVEN valid permission with options THEN does not throw error', () => {
expect(() =>
getBuilder().addBooleanOption(getBooleanOption()).setDefaultMemberPermissions('1'),
).not.toThrowError();
expect(() => getBuilder().addChannelOption(getChannelOption()).setDMPermission(false)).not.toThrowError();
});
});
describe('contexts', () => {
test('GIVEN a builder with valid contexts THEN does not throw an error', () => {
expect(() =>
getBuilder().setContexts([InteractionContextType.Guild, InteractionContextType.BotDM]),
).not.toThrowError();
expect(() =>
getBuilder().setContexts(InteractionContextType.Guild, InteractionContextType.BotDM),
).not.toThrowError();
});
test('GIVEN a builder with invalid contexts THEN does throw an error', () => {
// @ts-expect-error: Invalid contexts
expect(() => getBuilder().setContexts(999)).toThrowError();
// @ts-expect-error: Invalid contexts
expect(() => getBuilder().setContexts([999, 998])).toThrowError();
});
});
describe('integration types', () => {
test('GIVEN a builder with valid integration types THEN does not throw an error', () => {
expect(() =>
getBuilder().setIntegrationTypes([
ApplicationIntegrationType.GuildInstall,
ApplicationIntegrationType.UserInstall,
]),
).not.toThrowError();
expect(() =>
getBuilder().setIntegrationTypes(
ApplicationIntegrationType.GuildInstall,
ApplicationIntegrationType.UserInstall,
),
).not.toThrowError();
});
test('GIVEN a builder with invalid integration types THEN does throw an error', () => {
// @ts-expect-error: Invalid integration types
expect(() => getBuilder().setIntegrationTypes(999)).toThrowError();
// @ts-expect-error: Invalid integration types
expect(() => getBuilder().setIntegrationTypes([999, 998])).toThrowError();
});
});
});
});

View File

@@ -1,21 +1,71 @@
import { ComponentType, TextInputStyle, type APIModalInteractionResponseCallbackData } from 'discord-api-types/v10';
import {
ComponentType,
TextInputStyle,
type APIModalInteractionResponseCallbackData,
type APITextInputComponent,
} from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { ActionRowBuilder, ModalBuilder, TextInputBuilder } from '../../src/index.js';
import {
ActionRowBuilder,
ButtonBuilder,
ModalBuilder,
TextInputBuilder,
type ModalActionRowComponentBuilder,
} from '../../src/index.js';
import {
componentsValidator,
titleValidator,
validateRequiredParameters,
} from '../../src/interactions/modals/Assertions.js';
const modal = () => new ModalBuilder();
const textInput = () =>
new ActionRowBuilder().addTextInputComponent(
new TextInputBuilder().setCustomId('text').setLabel(':3').setStyle(TextInputStyle.Short),
);
describe('Modals', () => {
describe('Assertion Tests', () => {
test('GIVEN valid title THEN validator does not throw', () => {
expect(() => titleValidator.parse('foobar')).not.toThrowError();
});
test('GIVEN invalid title THEN validator does throw', () => {
expect(() => titleValidator.parse(42)).toThrowError();
});
test('GIVEN valid components THEN validator does not throw', () => {
expect(() => componentsValidator.parse([new ActionRowBuilder(), new ActionRowBuilder()])).not.toThrowError();
});
test('GIVEN invalid components THEN validator does throw', () => {
expect(() => componentsValidator.parse([new ButtonBuilder(), new TextInputBuilder()])).toThrowError();
});
test('GIVEN valid required parameters THEN validator does not throw', () => {
expect(() =>
validateRequiredParameters('123', 'title', [new ActionRowBuilder(), new ActionRowBuilder()]),
).not.toThrowError();
});
test('GIVEN invalid required parameters THEN validator does throw', () => {
expect(() =>
// @ts-expect-error: Missing required parameter
validateRequiredParameters('123', undefined, [new ActionRowBuilder(), new ButtonBuilder()]),
).toThrowError();
});
});
test('GIVEN valid fields THEN builder does not throw', () => {
expect(() => modal().setTitle('test').setCustomId('foobar').setActionRows(textInput()).toJSON()).not.toThrowError();
expect(() => modal().setTitle('test').setCustomId('foobar').addActionRows(textInput()).toJSON()).not.toThrowError();
expect(() =>
modal().setTitle('test').setCustomId('foobar').setComponents(new ActionRowBuilder()),
).not.toThrowError();
expect(() =>
// @ts-expect-error: You can pass a TextInputBuilder and it will add it to an action row
modal().setTitle('test').setCustomId('foobar').addComponents(new TextInputBuilder()),
).not.toThrowError();
});
test('GIVEN invalid fields THEN builder does throw', () => {
expect(() => modal().setTitle('test').setCustomId('foobar').toJSON()).toThrowError();
// @ts-expect-error: CustomId is invalid
expect(() => modal().setTitle('test').setCustomId(42).toJSON()).toThrowError();
});
@@ -56,17 +106,68 @@ describe('Modals', () => {
modal()
.setTitle(modalData.title)
.setCustomId('custom id')
.setActionRows(
new ActionRowBuilder().addTextInputComponent(
.setComponents(
new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(
new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph),
),
)
.addActionRows([
new ActionRowBuilder().addTextInputComponent(
.addComponents([
new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(
new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph),
),
])
.toJSON(),
).toEqual(modalData);
});
describe('equals()', () => {
const textInput1 = new TextInputBuilder()
.setCustomId('custom id')
.setLabel('label')
.setStyle(TextInputStyle.Paragraph);
const textInput2: APITextInputComponent = {
type: ComponentType.TextInput,
custom_id: 'custom id',
label: 'label',
style: TextInputStyle.Paragraph,
};
test('GIVEN equal builders THEN returns true', () => {
const equalTextInput = new TextInputBuilder()
.setCustomId('custom id')
.setLabel('label')
.setStyle(TextInputStyle.Paragraph);
expect(textInput1.equals(equalTextInput)).toBeTruthy();
});
test('GIVEN the same builder THEN returns true', () => {
expect(textInput1.equals(textInput1)).toBeTruthy();
});
test('GIVEN equal builder and data THEN returns true', () => {
expect(textInput1.equals(textInput2)).toBeTruthy();
});
test('GIVEN different builders THEN returns false', () => {
const diffTextInput = new TextInputBuilder()
.setCustomId('custom id')
.setLabel('label 2')
.setStyle(TextInputStyle.Paragraph);
expect(textInput1.equals(diffTextInput)).toBeFalsy();
});
test('GIVEN different text input builder and data THEN returns false', () => {
const diffTextInputData: APITextInputComponent = {
type: ComponentType.TextInput,
custom_id: 'custom id',
label: 'label 2',
style: TextInputStyle.Short,
};
expect(textInput1.equals(diffTextInputData)).toBeFalsy();
});
});
});

View File

@@ -3,16 +3,6 @@ import { EmbedBuilder, embedLength } from '../../src/index.js';
const alpha = 'abcdefghijklmnopqrstuvwxyz';
const dummy = {
title: 'ooooo aaaaa uuuuuu aaaa',
};
const base = {
author: undefined,
fields: [],
footer: undefined,
};
describe('Embed', () => {
describe('Embed getters', () => {
test('GIVEN an embed with specific amount of characters THEN returns amount of characters', () => {
@@ -24,136 +14,127 @@ describe('Embed', () => {
footer: { text: alpha },
});
expect(embedLength(embed.toJSON())).toEqual(alpha.length * 6);
expect(embedLength(embed.data)).toEqual(alpha.length * 6);
});
test('GIVEN an embed with zero characters THEN returns amount of characters', () => {
const embed = new EmbedBuilder();
expect(embedLength(embed.toJSON(false))).toEqual(0);
expect(embedLength(embed.data)).toEqual(0);
});
});
describe('Embed title', () => {
test('GIVEN an embed with a pre-defined title THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ title: 'foo' });
expect(embed.toJSON()).toStrictEqual({ ...base, title: 'foo' });
expect(embed.toJSON()).toStrictEqual({ title: 'foo' });
});
test('GIVEN an embed using Embed#setTitle THEN return valid toJSON data', () => {
const embed = new EmbedBuilder();
embed.setTitle('foo');
expect(embed.toJSON()).toStrictEqual({ ...base, title: 'foo' });
expect(embed.toJSON()).toStrictEqual({ title: 'foo' });
});
test('GIVEN an embed with a pre-defined title THEN unset title THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ title: 'foo', description: ':3' });
embed.clearTitle();
const embed = new EmbedBuilder({ title: 'foo' });
embed.setTitle(null);
expect(embed.toJSON()).toStrictEqual({ ...base, description: ':3', title: undefined });
expect(embed.toJSON()).toStrictEqual({ title: undefined });
});
test('GIVEN an embed with an invalid title THEN throws error', () => {
const embed = new EmbedBuilder();
embed.setTitle('a'.repeat(257));
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.setTitle('a'.repeat(257))).toThrowError();
});
});
describe('Embed description', () => {
test('GIVEN an embed with a pre-defined description THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ description: 'foo' });
expect(embed.toJSON()).toStrictEqual({ ...base, description: 'foo' });
expect(embed.toJSON()).toStrictEqual({ description: 'foo' });
});
test('GIVEN an embed using Embed#setDescription THEN return valid toJSON data', () => {
const embed = new EmbedBuilder();
embed.setDescription('foo');
expect(embed.toJSON()).toStrictEqual({ ...base, description: 'foo' });
expect(embed.toJSON()).toStrictEqual({ description: 'foo' });
});
test('GIVEN an embed with a pre-defined description THEN unset description THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ description: 'foo', ...dummy });
embed.clearDescription();
const embed = new EmbedBuilder({ description: 'foo' });
embed.setDescription(null);
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, description: undefined });
expect(embed.toJSON()).toStrictEqual({ description: undefined });
});
test('GIVEN an embed with an invalid description THEN throws error', () => {
const embed = new EmbedBuilder();
embed.setDescription('a'.repeat(4_097));
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.setDescription('a'.repeat(4_097))).toThrowError();
});
});
describe('Embed URL', () => {
test('GIVEN an embed with a pre-defined url THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder({ url: 'https://discord.js.org/', ...dummy });
const embed = new EmbedBuilder({ url: 'https://discord.js.org/' });
expect(embed.toJSON()).toStrictEqual({
...base,
...dummy,
url: 'https://discord.js.org/',
});
});
test('GIVEN an embed using Embed#setURL THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder(dummy);
const embed = new EmbedBuilder();
embed.setURL('https://discord.js.org/');
expect(embed.toJSON()).toStrictEqual({
...base,
...dummy,
url: 'https://discord.js.org/',
});
});
test('GIVEN an embed with a pre-defined title THEN unset title THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ url: 'https://discord.js.org', ...dummy });
embed.clearURL();
const embed = new EmbedBuilder({ url: 'https://discord.js.org' });
embed.setURL(null);
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, url: undefined });
expect(embed.toJSON()).toStrictEqual({ url: undefined });
});
test.each(['owo', 'discord://user'])('GIVEN an embed with an invalid URL THEN throws error', (input) => {
const embed = new EmbedBuilder();
embed.setURL(input);
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.setURL(input)).toThrowError();
});
});
describe('Embed Color', () => {
test('GIVEN an embed with a pre-defined color THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder({ color: 0xff0000, ...dummy });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, color: 0xff0000 });
const embed = new EmbedBuilder({ color: 0xff0000 });
expect(embed.toJSON()).toStrictEqual({ color: 0xff0000 });
});
test('GIVEN an embed using Embed#setColor THEN returns valid toJSON data', () => {
expect(new EmbedBuilder(dummy).setColor(0xff0000).toJSON()).toStrictEqual({ ...base, ...dummy, color: 0xff0000 });
expect(new EmbedBuilder().setColor(0xff0000).toJSON()).toStrictEqual({ color: 0xff0000 });
expect(new EmbedBuilder().setColor([242, 66, 245]).toJSON()).toStrictEqual({ color: 0xf242f5 });
});
test('GIVEN an embed with a pre-defined color THEN unset color THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ ...dummy, color: 0xff0000 });
embed.clearColor();
const embed = new EmbedBuilder({ color: 0xff0000 });
embed.setColor(null);
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, color: undefined });
expect(embed.toJSON()).toStrictEqual({ color: undefined });
});
test('GIVEN an embed with an invalid color THEN throws error', () => {
const embed = new EmbedBuilder();
// @ts-expect-error: Invalid color
embed.setColor('RED');
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.setColor('RED')).toThrowError();
// @ts-expect-error: Invalid color
embed.setColor([42, 36]);
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.setColor([42, 36])).toThrowError();
expect(() => embed.setColor([42, 36, 1_000])).toThrowError();
});
});
@@ -161,92 +142,98 @@ describe('Embed', () => {
const now = new Date();
test('GIVEN an embed with a pre-defined timestamp THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder({ timestamp: now.toISOString(), ...dummy });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: now.toISOString() });
const embed = new EmbedBuilder({ timestamp: now.toISOString() });
expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() });
});
test('GIVEN an embed using Embed#setTimestamp (with Date) THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder(dummy);
test('given an embed using Embed#setTimestamp (with Date) THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
embed.setTimestamp(now);
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: now.toISOString() });
expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() });
});
test('GIVEN an embed using Embed#setTimestamp (with int) THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder(dummy);
const embed = new EmbedBuilder();
embed.setTimestamp(now.getTime());
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: now.toISOString() });
expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() });
});
test('GIVEN an embed using Embed#setTimestamp (default) THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder(dummy);
const embed = new EmbedBuilder();
embed.setTimestamp();
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: embed.toJSON().timestamp });
expect(embed.toJSON()).toStrictEqual({ timestamp: embed.data.timestamp });
});
test('GIVEN an embed with a pre-defined timestamp THEN unset timestamp THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ timestamp: now.toISOString(), ...dummy });
embed.clearTimestamp();
const embed = new EmbedBuilder({ timestamp: now.toISOString() });
embed.setTimestamp(null);
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: undefined });
expect(embed.toJSON()).toStrictEqual({ timestamp: undefined });
});
});
describe('Embed Thumbnail', () => {
test('GIVEN an embed with a pre-defined thumbnail THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder({ thumbnail: { url: 'https://discord.js.org/static/logo.svg' } });
expect(embed.toJSON()).toStrictEqual({ ...base, thumbnail: { url: 'https://discord.js.org/static/logo.svg' } });
expect(embed.toJSON()).toStrictEqual({
thumbnail: { url: 'https://discord.js.org/static/logo.svg' },
});
});
test('GIVEN an embed using Embed#setThumbnail THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
embed.setThumbnail('https://discord.js.org/static/logo.svg');
expect(embed.toJSON()).toStrictEqual({ ...base, thumbnail: { url: 'https://discord.js.org/static/logo.svg' } });
expect(embed.toJSON()).toStrictEqual({
thumbnail: { url: 'https://discord.js.org/static/logo.svg' },
});
});
test('GIVEN an embed with a pre-defined thumbnail THEN unset thumbnail THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ thumbnail: { url: 'https://discord.js.org/static/logo.svg' }, ...dummy });
embed.clearThumbnail();
const embed = new EmbedBuilder({ thumbnail: { url: 'https://discord.js.org/static/logo.svg' } });
embed.setThumbnail(null);
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, thumbnail: undefined });
expect(embed.toJSON()).toStrictEqual({ thumbnail: undefined });
});
test('GIVEN an embed with an invalid thumbnail THEN throws error', () => {
const embed = new EmbedBuilder();
embed.setThumbnail('owo');
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.setThumbnail('owo')).toThrowError();
});
});
describe('Embed Image', () => {
test('GIVEN an embed with a pre-defined image THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder({ image: { url: 'https://discord.js.org/static/logo.svg' } });
expect(embed.toJSON()).toStrictEqual({ ...base, image: { url: 'https://discord.js.org/static/logo.svg' } });
expect(embed.toJSON()).toStrictEqual({
image: { url: 'https://discord.js.org/static/logo.svg' },
});
});
test('GIVEN an embed using Embed#setImage THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
embed.setImage('https://discord.js.org/static/logo.svg');
expect(embed.toJSON()).toStrictEqual({ ...base, image: { url: 'https://discord.js.org/static/logo.svg' } });
expect(embed.toJSON()).toStrictEqual({
image: { url: 'https://discord.js.org/static/logo.svg' },
});
});
test('GIVEN an embed with a pre-defined image THEN unset image THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ image: { url: 'https://discord.js/org/static/logo.svg' }, ...dummy });
embed.clearImage();
const embed = new EmbedBuilder({ image: { url: 'https://discord.js/org/static/logo.svg' } });
embed.setImage(null);
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, image: undefined });
expect(embed.toJSON()).toStrictEqual({ image: undefined });
});
test('GIVEN an embed with an invalid image THEN throws error', () => {
const embed = new EmbedBuilder();
embed.setImage('owo');
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.setImage('owo')).toThrowError();
});
});
@@ -256,19 +243,19 @@ describe('Embed', () => {
author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' },
});
expect(embed.toJSON()).toStrictEqual({
...base,
author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' },
});
});
test('GIVEN an embed using Embed#setAuthor THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
embed.setAuthor((author) =>
author.setName('Wumpus').setIconURL('https://discord.js.org/static/logo.svg').setURL('https://discord.js.org'),
);
embed.setAuthor({
name: 'Wumpus',
iconURL: 'https://discord.js.org/static/logo.svg',
url: 'https://discord.js.org',
});
expect(embed.toJSON()).toStrictEqual({
...base,
author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' },
});
});
@@ -276,18 +263,16 @@ describe('Embed', () => {
test('GIVEN an embed with a pre-defined author THEN unset author THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({
author: { name: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg', url: 'https://discord.js.org' },
...dummy,
});
embed.clearAuthor();
embed.setAuthor(null);
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, author: undefined });
expect(embed.toJSON()).toStrictEqual({ author: undefined });
});
test('GIVEN an embed with an invalid author name THEN throws error', () => {
const embed = new EmbedBuilder();
embed.setAuthor({ name: 'a'.repeat(257) });
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.setAuthor({ name: 'a'.repeat(257) })).toThrowError();
});
});
@@ -297,36 +282,32 @@ describe('Embed', () => {
footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' },
});
expect(embed.toJSON()).toStrictEqual({
...base,
footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' },
});
});
test('GIVEN an embed using Embed#setAuthor THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
embed.setFooter({ text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' });
embed.setFooter({ text: 'Wumpus', iconURL: 'https://discord.js.org/static/logo.svg' });
expect(embed.toJSON()).toStrictEqual({
...base,
footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' },
});
});
test('GIVEN an embed with a pre-defined footer THEN unset footer THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({
...dummy,
footer: { text: 'Wumpus', icon_url: 'https://discord.js.org/static/logo.svg' },
});
embed.clearFooter();
embed.setFooter(null);
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, footer: undefined });
expect(embed.toJSON()).toStrictEqual({ footer: undefined });
});
test('GIVEN an embed with invalid footer text THEN throws error', () => {
const embed = new EmbedBuilder();
embed.setFooter({ text: 'a'.repeat(2_049) });
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.setFooter({ text: 'a'.repeat(2_049) })).toThrowError();
});
});
@@ -335,7 +316,9 @@ describe('Embed', () => {
const embed = new EmbedBuilder({
fields: [{ name: 'foo', value: 'bar' }],
});
expect(embed.toJSON()).toStrictEqual({ ...base, fields: [{ name: 'foo', value: 'bar' }] });
expect(embed.toJSON()).toStrictEqual({
fields: [{ name: 'foo', value: 'bar' }],
});
});
test('GIVEN an embed using Embed#addFields THEN returns valid toJSON data', () => {
@@ -344,7 +327,6 @@ describe('Embed', () => {
embed.addFields([{ name: 'foo', value: 'bar' }]);
expect(embed.toJSON()).toStrictEqual({
...base,
fields: [
{ name: 'foo', value: 'bar' },
{ name: 'foo', value: 'bar' },
@@ -356,51 +338,56 @@ describe('Embed', () => {
const embed = new EmbedBuilder();
embed.addFields({ name: 'foo', value: 'bar' }, { name: 'foo', value: 'baz' });
expect(embed.spliceFields(0, 1).toJSON()).toStrictEqual({ ...base, fields: [{ name: 'foo', value: 'baz' }] });
expect(embed.spliceFields(0, 1).toJSON()).toStrictEqual({
fields: [{ name: 'foo', value: 'baz' }],
});
});
test('GIVEN an embed using Embed#spliceFields THEN returns valid toJSON data 2', () => {
const embed = new EmbedBuilder();
embed.addFields(...Array.from({ length: 23 }, () => ({ name: 'foo', value: 'bar' })));
embed.spliceFields(0, 3, ...Array.from({ length: 5 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).not.toThrowError();
expect(() =>
embed.spliceFields(0, 3, ...Array.from({ length: 5 }, () => ({ name: 'foo', value: 'bar' }))),
).not.toThrowError();
});
test('GIVEN an embed using Embed#spliceFields that adds additional fields resulting in fields > 25 THEN throws error', () => {
const embed = new EmbedBuilder();
embed.addFields(...Array.from({ length: 23 }, () => ({ name: 'foo', value: 'bar' })));
embed.spliceFields(0, 3, ...Array.from({ length: 8 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).toThrowError();
expect(() =>
embed.spliceFields(0, 3, ...Array.from({ length: 8 }, () => ({ name: 'foo', value: 'bar' }))),
).toThrowError();
});
test('GIVEN an embed using Embed#setFields THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
embed.setFields(...Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).not.toThrowError();
embed.setFields(Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).not.toThrowError();
expect(() =>
embed.setFields(...Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' }))),
).not.toThrowError();
expect(() =>
embed.setFields(Array.from({ length: 25 }, () => ({ name: 'foo', value: 'bar' }))),
).not.toThrowError();
});
test('GIVEN an embed using Embed#setFields that sets more than 25 fields THEN throws error', () => {
const embed = new EmbedBuilder();
embed.setFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).toThrowError();
embed.setFields(Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).toThrowError();
expect(() =>
embed.setFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))),
).toThrowError();
expect(() => embed.setFields(Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })))).toThrowError();
});
describe('GIVEN invalid field amount THEN throws error', () => {
test('1', () => {
const embed = new EmbedBuilder();
embed.addFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).toThrowError();
expect(() =>
embed.addFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))),
).toThrowError();
});
});
@@ -408,8 +395,7 @@ describe('Embed', () => {
test('2', () => {
const embed = new EmbedBuilder();
embed.addFields({ name: '', value: 'bar' });
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.addFields({ name: '', value: 'bar' })).toThrowError();
});
});
@@ -417,8 +403,7 @@ describe('Embed', () => {
test('3', () => {
const embed = new EmbedBuilder();
embed.addFields({ name: 'a'.repeat(257), value: 'bar' });
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.addFields({ name: 'a'.repeat(257), value: 'bar' })).toThrowError();
});
});
@@ -426,8 +411,7 @@ describe('Embed', () => {
test('4', () => {
const embed = new EmbedBuilder();
embed.addFields({ name: '', value: 'a'.repeat(1_025) });
expect(() => embed.toJSON()).toThrowError();
expect(() => embed.addFields({ name: '', value: 'a'.repeat(1_025) })).toThrowError();
});
});
});

View File

@@ -1,21 +1,17 @@
import { expectTypeOf } from 'vitest';
import {
ChatInputCommandBuilder,
ChatInputCommandStringOption,
ChatInputCommandSubcommandBuilder,
} from '../src/index.js';
import { SlashCommandBuilder, SlashCommandStringOption, SlashCommandSubcommandBuilder } from '../src/index.js';
const getBuilder = () => new ChatInputCommandBuilder();
const getStringOption = () => new ChatInputCommandStringOption().setName('owo').setDescription('Testing 123');
const getSubcommand = () => new ChatInputCommandSubcommandBuilder().setName('owo').setDescription('Testing 123');
const getBuilder = () => new SlashCommandBuilder();
const getStringOption = () => new SlashCommandStringOption().setName('owo').setDescription('Testing 123');
const getSubcommand = () => new SlashCommandSubcommandBuilder().setName('owo').setDescription('Testing 123');
type BuilderPropsOnly<Type = ChatInputCommandBuilder> = Pick<
type BuilderPropsOnly<Type = SlashCommandBuilder> = Pick<
Type,
keyof {
[Key in keyof Type as Type[Key] extends (...args: any) => any ? never : Key]: any;
}
>;
expectTypeOf(getBuilder().addStringOptions(getStringOption())).toMatchTypeOf<BuilderPropsOnly>();
expectTypeOf(getBuilder().addStringOption(getStringOption())).toMatchTypeOf<BuilderPropsOnly>();
expectTypeOf(getBuilder().addSubcommands(getSubcommand())).toMatchTypeOf<BuilderPropsOnly>();
expectTypeOf(getBuilder().addSubcommand(getSubcommand())).toMatchTypeOf<BuilderPropsOnly>();

View File

@@ -65,18 +65,19 @@
"homepage": "https://discord.js.org",
"funding": "https://github.com/discordjs/discord.js?sponsor",
"dependencies": {
"@discordjs/formatters": "workspace:^",
"@discordjs/util": "workspace:^",
"discord-api-types": "^0.37.103",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.37.119",
"fast-deep-equal": "^3.1.3",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.4.0"
"tslib": "^2.6.3"
},
"devDependencies": {
"@discordjs/api-extractor": "workspace:^",
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@types/node": "^18.19.44",
"@types/node": "^16.18.105",
"@vitest/coverage-v8": "^2.0.5",
"cross-env": "^7.0.3",
"esbuild-plugin-version-injector": "^1.2.1",

View File

@@ -1,20 +0,0 @@
import { Locale } from 'discord-api-types/v10';
import { z } from 'zod';
export const customIdPredicate = z.string().min(1).max(100);
export const memberPermissionsPredicate = z.coerce.bigint();
export const localeMapPredicate = z
.object(
Object.fromEntries(Object.values(Locale).map((loc) => [loc, z.string().optional()])) as Record<
Locale,
z.ZodOptional<z.ZodString>
>,
)
.strict();
export const refineURLPredicate = (allowedProtocols: string[]) => (value: string) => {
const url = new URL(value);
return allowedProtocols.includes(url.protocol);
};

View File

@@ -1,60 +1,68 @@
/* eslint-disable jsdoc/check-param-names */
import type {
APITextInputComponent,
APIActionRowComponent,
APIActionRowComponentTypes,
APIChannelSelectComponent,
APIMentionableSelectComponent,
APIRoleSelectComponent,
APIStringSelectComponent,
APIUserSelectComponent,
APIButtonComponentWithCustomId,
APIButtonComponentWithSKUId,
APIButtonComponentWithURL,
} from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js';
import { resolveBuilder } from '../util/resolveBuilder.js';
import { validate } from '../util/validation.js';
import { actionRowPredicate } from './Assertions.js';
import { ComponentBuilder } from './Component.js';
import type { AnyActionRowComponentBuilder } from './Components.js';
import { createComponentBuilder } from './Components.js';
import {
DangerButtonBuilder,
PrimaryButtonBuilder,
SecondaryButtonBuilder,
SuccessButtonBuilder,
} from './button/CustomIdButton.js';
import { LinkButtonBuilder } from './button/LinkButton.js';
import { PremiumButtonBuilder } from './button/PremiumButton.js';
import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js';
import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js';
import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js';
import { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js';
import { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js';
import { TextInputBuilder } from './textInput/TextInput.js';
type APIActionRowComponent,
ComponentType,
type APIMessageActionRowComponent,
type APIModalActionRowComponent,
type APIActionRowComponentTypes,
} from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../util/normalizeArray.js';
import { ComponentBuilder } from './Component.js';
import { createComponentBuilder } from './Components.js';
import type { ButtonBuilder } from './button/Button.js';
import type { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js';
import type { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js';
import type { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js';
import type { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js';
import type { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js';
import type { TextInputBuilder } from './textInput/TextInput.js';
export interface ActionRowBuilderData
extends Partial<Omit<APIActionRowComponent<APIActionRowComponentTypes>, 'components'>> {
components: AnyActionRowComponentBuilder[];
}
/**
* The builders that may be used for messages.
*/
export type MessageComponentBuilder =
| ActionRowBuilder<MessageActionRowComponentBuilder>
| MessageActionRowComponentBuilder;
/**
* The builders that may be used for modals.
*/
export type ModalComponentBuilder = ActionRowBuilder<ModalActionRowComponentBuilder> | ModalActionRowComponentBuilder;
/**
* The builders that may be used within an action row for messages.
*/
export type MessageActionRowComponentBuilder =
| ButtonBuilder
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| UserSelectMenuBuilder;
/**
* The builders that may be used within an action row for modals.
*/
export type ModalActionRowComponentBuilder = TextInputBuilder;
/**
* Any builder.
*/
export type AnyComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder;
/**
* A builder that creates API-compatible JSON data for action rows.
*
* @typeParam ComponentType - The types of components this action row holds
*/
export class ActionRowBuilder extends ComponentBuilder<APIActionRowComponent<APIActionRowComponentTypes>> {
private readonly data: ActionRowBuilderData;
export class ActionRowBuilder<ComponentType extends AnyComponentBuilder> extends ComponentBuilder<
APIActionRowComponent<APIMessageActionRowComponent | APIModalActionRowComponent>
> {
/**
* The components within this action row.
*/
public get components(): readonly AnyActionRowComponentBuilder[] {
return this.data.components;
}
public readonly components: ComponentType[];
/**
* Creates a new action row from API data.
@@ -90,254 +98,38 @@ export class ActionRowBuilder extends ComponentBuilder<APIActionRowComponent<API
* .addComponents(button2, button3);
* ```
*/
public constructor({ components = [], ...data }: Partial<APIActionRowComponent<APIActionRowComponentTypes>> = {}) {
super();
this.data = {
...structuredClone(data),
type: ComponentType.ActionRow,
components: components.map((component) => createComponentBuilder(component)),
};
public constructor({ components, ...data }: Partial<APIActionRowComponent<APIActionRowComponentTypes>> = {}) {
super({ type: ComponentType.ActionRow, ...data });
this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ComponentType[];
}
/**
* Adds primary button components to this action row.
* Adds components to this action row.
*
* @param input - The buttons to add
* @param components - The components to add
*/
public addPrimaryButtonComponents(
...input: RestOrArray<
APIButtonComponentWithCustomId | PrimaryButtonBuilder | ((builder: PrimaryButtonBuilder) => PrimaryButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, PrimaryButtonBuilder));
this.data.components.push(...resolved);
public addComponents(...components: RestOrArray<ComponentType>) {
this.components.push(...normalizeArray(components));
return this;
}
/**
* Adds secondary button components to this action row.
* Sets components for this action row.
*
* @param input - The buttons to add
* @param components - The components to set
*/
public addSecondaryButtonComponents(
...input: RestOrArray<
| APIButtonComponentWithCustomId
| SecondaryButtonBuilder
| ((builder: SecondaryButtonBuilder) => SecondaryButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, SecondaryButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds success button components to this action row.
*
* @param input - The buttons to add
*/
public addSuccessButtonComponents(
...input: RestOrArray<
APIButtonComponentWithCustomId | SuccessButtonBuilder | ((builder: SuccessButtonBuilder) => SuccessButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, SuccessButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds danger button components to this action row.
*/
public addDangerButtonComponents(
...input: RestOrArray<
APIButtonComponentWithCustomId | DangerButtonBuilder | ((builder: DangerButtonBuilder) => DangerButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, DangerButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Generically add any type of component to this action row, only takes in an instance of a component builder.
*/
public addComponents(...input: RestOrArray<AnyActionRowComponentBuilder>): this {
const normalized = normalizeArray(input);
this.data.components.push(...normalized);
return this;
}
/**
* Adds SKU id button components to this action row.
*
* @param input - The buttons to add
*/
public addPremiumButtonComponents(
...input: RestOrArray<
APIButtonComponentWithSKUId | PremiumButtonBuilder | ((builder: PremiumButtonBuilder) => PremiumButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, PremiumButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds URL button components to this action row.
*
* @param input - The buttons to add
*/
public addLinkButtonComponents(
...input: RestOrArray<
APIButtonComponentWithURL | LinkButtonBuilder | ((builder: LinkButtonBuilder) => LinkButtonBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((component) => resolveBuilder(component, LinkButtonBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Adds a channel select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addChannelSelectMenuComponent(
input:
| APIChannelSelectComponent
| ChannelSelectMenuBuilder
| ((builder: ChannelSelectMenuBuilder) => ChannelSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, ChannelSelectMenuBuilder));
return this;
}
/**
* Adds a mentionable select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addMentionableSelectMenuComponent(
input:
| APIMentionableSelectComponent
| MentionableSelectMenuBuilder
| ((builder: MentionableSelectMenuBuilder) => MentionableSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, MentionableSelectMenuBuilder));
return this;
}
/**
* Adds a role select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addRoleSelectMenuComponent(
input: APIRoleSelectComponent | RoleSelectMenuBuilder | ((builder: RoleSelectMenuBuilder) => RoleSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, RoleSelectMenuBuilder));
return this;
}
/**
* Adds a string select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addStringSelectMenuComponent(
input:
| APIStringSelectComponent
| StringSelectMenuBuilder
| ((builder: StringSelectMenuBuilder) => StringSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, StringSelectMenuBuilder));
return this;
}
/**
* Adds a user select menu component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addUserSelectMenuComponent(
input: APIUserSelectComponent | UserSelectMenuBuilder | ((builder: UserSelectMenuBuilder) => UserSelectMenuBuilder),
): this {
this.data.components.push(resolveBuilder(input, UserSelectMenuBuilder));
return this;
}
/**
* Adds a text input component to this action row.
*
* @param input - A function that returns a component builder or an already built builder
*/
public addTextInputComponent(
input: APITextInputComponent | TextInputBuilder | ((builder: TextInputBuilder) => TextInputBuilder),
): this {
this.data.components.push(resolveBuilder(input, TextInputBuilder));
return this;
}
/**
* Removes, replaces, or inserts components for this action row.
*
* @remarks
* This method behaves similarly
* to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}.
*
* It's useful for modifying and adjusting order of the already-existing components of an action row.
* @example
* Remove the first component:
* ```ts
* actionRow.spliceComponents(0, 1);
* ```
* @example
* Remove the first n components:
* ```ts
* const n = 4;
* actionRow.spliceComponents(0, n);
* ```
* @example
* Remove the last component:
* ```ts
* actionRow.spliceComponents(-1, 1);
* ```
* @param index - The index to start at
* @param deleteCount - The number of components to remove
* @param components - The replacing component objects
*/
public spliceComponents(index: number, deleteCount: number, ...components: AnyActionRowComponentBuilder[]): this {
this.data.components.splice(index, deleteCount, ...components);
public setComponents(...components: RestOrArray<ComponentType>) {
this.components.splice(0, this.components.length, ...normalizeArray(components));
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIActionRowComponent<APIActionRowComponentTypes> {
const { components, ...rest } = this.data;
const data = {
...structuredClone(rest),
components: components.map((component) => component.toJSON(validationOverride)),
};
validate(actionRowPredicate, data, validationOverride);
return data as APIActionRowComponent<APIActionRowComponentTypes>;
public toJSON(): APIActionRowComponent<ReturnType<ComponentType['toJSON']>> {
return {
...this.data,
components: this.components.map((component) => component.toJSON()),
} as APIActionRowComponent<ReturnType<ComponentType['toJSON']>>;
}
}

View File

@@ -1,168 +1,127 @@
import { ButtonStyle, ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate, refineURLPredicate } from '../Assertions.js';
import { s } from '@sapphire/shapeshift';
import { ButtonStyle, ChannelType, type APIMessageComponentEmoji } from 'discord-api-types/v10';
import { isValidationEnabled } from '../util/validation.js';
import { StringSelectMenuOptionBuilder } from './selectMenu/StringSelectMenuOption.js';
const labelPredicate = z.string().min(1).max(80);
export const customIdValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(100)
.setValidationEnabled(isValidationEnabled);
export const emojiPredicate = z
export const emojiValidator = s
.object({
id: z.string().optional(),
name: z.string().min(2).max(32).optional(),
animated: z.boolean().optional(),
id: s.string(),
name: s.string(),
animated: s.boolean(),
})
.partial()
.strict()
.refine((data) => data.id !== undefined || data.name !== undefined, {
message: "Either 'id' or 'name' must be provided",
});
.setValidationEnabled(isValidationEnabled);
const buttonPredicateBase = z.object({
type: z.literal(ComponentType.Button),
disabled: z.boolean().optional(),
});
export const disabledValidator = s.boolean();
const buttonCustomIdPredicateBase = buttonPredicateBase.extend({
custom_id: customIdPredicate,
emoji: emojiPredicate.optional(),
label: labelPredicate,
});
export const buttonLabelValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(80)
.setValidationEnabled(isValidationEnabled);
const buttonPrimaryPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Primary) }).strict();
const buttonSecondaryPredicate = buttonCustomIdPredicateBase
.extend({ style: z.literal(ButtonStyle.Secondary) })
.strict();
const buttonSuccessPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Success) }).strict();
const buttonDangerPredicate = buttonCustomIdPredicateBase.extend({ style: z.literal(ButtonStyle.Danger) }).strict();
export const buttonStyleValidator = s.nativeEnum(ButtonStyle);
const buttonLinkPredicate = buttonPredicateBase
.extend({
style: z.literal(ButtonStyle.Link),
url: z
.string()
.url()
.refine(refineURLPredicate(['http:', 'https:', 'discord:'])),
emoji: emojiPredicate.optional(),
label: labelPredicate,
export const placeholderValidator = s.string().lengthLessThanOrEqual(150).setValidationEnabled(isValidationEnabled);
export const minMaxValidator = s
.number()
.int()
.greaterThanOrEqual(0)
.lessThanOrEqual(25)
.setValidationEnabled(isValidationEnabled);
export const labelValueDescriptionValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(100)
.setValidationEnabled(isValidationEnabled);
export const jsonOptionValidator = s
.object({
label: labelValueDescriptionValidator,
value: labelValueDescriptionValidator,
description: labelValueDescriptionValidator.optional(),
emoji: emojiValidator.optional(),
default: s.boolean().optional(),
})
.strict();
.setValidationEnabled(isValidationEnabled);
const buttonPremiumPredicate = buttonPredicateBase
.extend({
style: z.literal(ButtonStyle.Premium),
sku_id: z.string(),
export const optionValidator = s.instance(StringSelectMenuOptionBuilder).setValidationEnabled(isValidationEnabled);
export const optionsValidator = optionValidator
.array()
.lengthGreaterThanOrEqual(0)
.setValidationEnabled(isValidationEnabled);
export const optionsLengthValidator = s
.number()
.int()
.greaterThanOrEqual(0)
.lessThanOrEqual(25)
.setValidationEnabled(isValidationEnabled);
export function validateRequiredSelectMenuParameters(options: StringSelectMenuOptionBuilder[], customId?: string) {
customIdValidator.parse(customId);
optionsValidator.parse(options);
}
export const defaultValidator = s.boolean();
export function validateRequiredSelectMenuOptionParameters(label?: string, value?: string) {
labelValueDescriptionValidator.parse(label);
labelValueDescriptionValidator.parse(value);
}
export const channelTypesValidator = s.nativeEnum(ChannelType).array().setValidationEnabled(isValidationEnabled);
export const urlValidator = s
.string()
.url({
allowedProtocols: ['http:', 'https:', 'discord:'],
})
.strict();
.setValidationEnabled(isValidationEnabled);
export const buttonPredicate = z.discriminatedUnion('style', [
buttonLinkPredicate,
buttonPrimaryPredicate,
buttonSecondaryPredicate,
buttonSuccessPredicate,
buttonDangerPredicate,
buttonPremiumPredicate,
]);
const selectMenuBasePredicate = z.object({
placeholder: z.string().max(150).optional(),
min_values: z.number().min(0).max(25).optional(),
max_values: z.number().min(0).max(25).optional(),
custom_id: customIdPredicate,
disabled: z.boolean().optional(),
});
export const selectMenuChannelPredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.ChannelSelect),
channel_types: z.nativeEnum(ChannelType).array().optional(),
default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Channel) })
.array()
.max(25)
.optional(),
});
export const selectMenuMentionablePredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.MentionableSelect),
default_values: z
.object({
id: z.string(),
type: z.union([z.literal(SelectMenuDefaultValueType.Role), z.literal(SelectMenuDefaultValueType.User)]),
})
.array()
.max(25)
.optional(),
});
export const selectMenuRolePredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.RoleSelect),
default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.Role) })
.array()
.max(25)
.optional(),
});
export const selectMenuStringOptionPredicate = z.object({
label: labelPredicate,
value: z.string().min(1).max(100),
description: z.string().min(1).max(100).optional(),
emoji: emojiPredicate.optional(),
default: z.boolean().optional(),
});
export const selectMenuStringPredicate = selectMenuBasePredicate
.extend({
type: z.literal(ComponentType.StringSelect),
options: selectMenuStringOptionPredicate.array().min(1).max(25),
})
.superRefine((menu, ctx) => {
const addIssue = (name: string, minimum: number) =>
ctx.addIssue({
code: 'too_small',
message: `The number of options must be greater than or equal to ${name}`,
inclusive: true,
minimum,
type: 'number',
path: ['options'],
});
if (menu.max_values !== undefined && menu.options.length < menu.max_values) {
addIssue('max_values', menu.max_values);
export function validateRequiredButtonParameters(
style?: ButtonStyle,
label?: string,
emoji?: APIMessageComponentEmoji,
customId?: string,
skuId?: string,
url?: string,
) {
if (style === ButtonStyle.Premium) {
if (!skuId) {
throw new RangeError('Premium buttons must have an SKU id.');
}
if (menu.min_values !== undefined && menu.options.length < menu.min_values) {
addIssue('min_values', menu.min_values);
if (customId || label || url || emoji) {
throw new RangeError('Premium buttons cannot have a custom id, label, URL, or emoji.');
}
} else {
if (skuId) {
throw new RangeError('Non-premium buttons must not have an SKU id.');
}
});
export const selectMenuUserPredicate = selectMenuBasePredicate.extend({
type: z.literal(ComponentType.UserSelect),
default_values: z
.object({ id: z.string(), type: z.literal(SelectMenuDefaultValueType.User) })
.array()
.max(25)
.optional(),
});
if (url && customId) {
throw new RangeError('URL and custom id are mutually exclusive.');
}
export const actionRowPredicate = z.object({
type: z.literal(ComponentType.ActionRow),
components: z.union([
z
.object({ type: z.literal(ComponentType.Button) })
.array()
.min(1)
.max(5),
z
.object({
type: z.union([
z.literal(ComponentType.ChannelSelect),
z.literal(ComponentType.MentionableSelect),
z.literal(ComponentType.RoleSelect),
z.literal(ComponentType.StringSelect),
z.literal(ComponentType.UserSelect),
// And this!
z.literal(ComponentType.TextInput),
]),
})
.array()
.length(1),
]),
});
if (!label && !emoji) {
throw new RangeError('Non-premium buttons must have a label and/or an emoji.');
}
if (style === ButtonStyle.Link) {
if (!url) {
throw new RangeError('Link buttons must have a URL.');
}
} else if (url) {
throw new RangeError('Non-premium and non-link buttons cannot have a URL.');
}
}
}

View File

@@ -1,5 +1,10 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIActionRowComponent, APIActionRowComponentTypes } from 'discord-api-types/v10';
import type {
APIActionRowComponent,
APIActionRowComponentTypes,
APIBaseComponent,
ComponentType,
} from 'discord-api-types/v10';
/**
* Any action row component data represented as an object.
@@ -9,15 +14,32 @@ export type AnyAPIActionRowComponent = APIActionRowComponent<APIActionRowCompone
/**
* The base component builder that contains common symbols for all sorts of components.
*
* @typeParam Component - The type of API data that is stored within the builder
* @typeParam DataType - The type of internal API data that is stored within the component
*/
export abstract class ComponentBuilder<Component extends AnyAPIActionRowComponent> implements JSONEncodable<Component> {
export abstract class ComponentBuilder<
DataType extends Partial<APIBaseComponent<ComponentType>> = APIBaseComponent<ComponentType>,
> implements JSONEncodable<AnyAPIActionRowComponent>
{
/**
* The API data associated with this component.
*/
public readonly data: Partial<DataType>;
/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
* @remarks
* This method runs validations on the data before serializing it.
* As such, it may throw an error if the data is invalid.
*/
public abstract toJSON(validationOverride?: boolean): Component;
public abstract toJSON(): AnyAPIActionRowComponent;
/**
* Constructs a new kind of component.
*
* @param data - The data to construct a component out of
*/
public constructor(data: Partial<DataType>) {
this.data = data;
}
}

View File

@@ -1,17 +1,12 @@
import type { APIButtonComponent, APIMessageComponent, APIModalComponent } from 'discord-api-types/v10';
import { ButtonStyle, ComponentType } from 'discord-api-types/v10';
import { ActionRowBuilder } from './ActionRow.js';
import type { AnyAPIActionRowComponent } from './Component.js';
import { ComponentBuilder } from './Component.js';
import type { BaseButtonBuilder } from './button/Button.js';
import { ComponentType, type APIMessageComponent, type APIModalComponent } from 'discord-api-types/v10';
import {
DangerButtonBuilder,
PrimaryButtonBuilder,
SecondaryButtonBuilder,
SuccessButtonBuilder,
} from './button/CustomIdButton.js';
import { LinkButtonBuilder } from './button/LinkButton.js';
import { PremiumButtonBuilder } from './button/PremiumButton.js';
ActionRowBuilder,
type AnyComponentBuilder,
type MessageComponentBuilder,
type ModalComponentBuilder,
} from './ActionRow.js';
import { ComponentBuilder } from './Component.js';
import { ButtonBuilder } from './button/Button.js';
import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js';
import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js';
import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js';
@@ -19,48 +14,6 @@ import { StringSelectMenuBuilder } from './selectMenu/StringSelectMenu.js';
import { UserSelectMenuBuilder } from './selectMenu/UserSelectMenu.js';
import { TextInputBuilder } from './textInput/TextInput.js';
/**
* The builders that may be used for messages.
*/
export type MessageComponentBuilder = ActionRowBuilder | MessageActionRowComponentBuilder;
/**
* The builders that may be used for modals.
*/
export type ModalComponentBuilder = ActionRowBuilder | ModalActionRowComponentBuilder;
/**
* Any button builder
*/
export type ButtonBuilder =
| DangerButtonBuilder
| LinkButtonBuilder
| PremiumButtonBuilder
| PrimaryButtonBuilder
| SecondaryButtonBuilder
| SuccessButtonBuilder;
/**
* The builders that may be used within an action row for messages.
*/
export type MessageActionRowComponentBuilder =
| ButtonBuilder
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| UserSelectMenuBuilder;
/**
* The builders that may be used within an action row for modals.
*/
export type ModalActionRowComponentBuilder = TextInputBuilder;
/**
* Any action row component builder.
*/
export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder;
/**
* Components here are mapped to their respective builder.
*/
@@ -68,9 +21,9 @@ export interface MappedComponentTypes {
/**
* The action row component type is associated with an {@link ActionRowBuilder}.
*/
[ComponentType.ActionRow]: ActionRowBuilder;
[ComponentType.ActionRow]: ActionRowBuilder<AnyComponentBuilder>;
/**
* The button component type is associated with a {@link BaseButtonBuilder}.
* The button component type is associated with a {@link ButtonBuilder}.
*/
[ComponentType.Button]: ButtonBuilder;
/**
@@ -122,7 +75,7 @@ export function createComponentBuilder<ComponentBuilder extends MessageComponent
export function createComponentBuilder(
data: APIMessageComponent | APIModalComponent | MessageComponentBuilder,
): ComponentBuilder<AnyAPIActionRowComponent> {
): ComponentBuilder {
if (data instanceof ComponentBuilder) {
return data;
}
@@ -131,7 +84,7 @@ export function createComponentBuilder(
case ComponentType.ActionRow:
return new ActionRowBuilder(data);
case ComponentType.Button:
return createButtonBuilder(data);
return new ButtonBuilder(data);
case ComponentType.StringSelect:
return new StringSelectMenuBuilder(data);
case ComponentType.TextInput:
@@ -149,23 +102,3 @@ export function createComponentBuilder(
throw new Error(`Cannot properly serialize component type: ${data.type}`);
}
}
function createButtonBuilder(data: APIButtonComponent): ButtonBuilder {
switch (data.style) {
case ButtonStyle.Primary:
return new PrimaryButtonBuilder(data);
case ButtonStyle.Secondary:
return new SecondaryButtonBuilder(data);
case ButtonStyle.Success:
return new SuccessButtonBuilder(data);
case ButtonStyle.Danger:
return new DangerButtonBuilder(data);
case ButtonStyle.Link:
return new LinkButtonBuilder(data);
case ButtonStyle.Premium:
return new PremiumButtonBuilder(data);
default:
// @ts-expect-error This case can still occur if we get a newer unsupported button style
throw new Error(`Cannot properly serialize button with style: ${data.style}`);
}
}

View File

@@ -1,13 +1,115 @@
import type { APIButtonComponent } from 'discord-api-types/v10';
import { validate } from '../../util/validation.js';
import { buttonPredicate } from '../Assertions.js';
import {
ComponentType,
type APIButtonComponent,
type APIButtonComponentWithCustomId,
type APIButtonComponentWithSKUId,
type APIButtonComponentWithURL,
type APIMessageComponentEmoji,
type ButtonStyle,
type Snowflake,
} from 'discord-api-types/v10';
import {
buttonLabelValidator,
buttonStyleValidator,
customIdValidator,
disabledValidator,
emojiValidator,
urlValidator,
validateRequiredButtonParameters,
} from '../Assertions.js';
import { ComponentBuilder } from '../Component.js';
/**
* A builder that creates API-compatible JSON data for buttons.
*/
export abstract class BaseButtonBuilder<ButtonData extends APIButtonComponent> extends ComponentBuilder<ButtonData> {
protected declare readonly data: Partial<ButtonData>;
export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
/**
* Creates a new button from API data.
*
* @param data - The API data to create this button with
* @example
* Creating a button from an API data object:
* ```ts
* const button = new ButtonBuilder({
* custom_id: 'a cool button',
* style: ButtonStyle.Primary,
* label: 'Click Me',
* emoji: {
* name: 'smile',
* id: '123456789012345678',
* },
* });
* ```
* @example
* Creating a button using setters and API data:
* ```ts
* const button = new ButtonBuilder({
* style: ButtonStyle.Secondary,
* label: 'Click Me',
* })
* .setEmoji({ name: '🙂' })
* .setCustomId('another cool button');
* ```
*/
public constructor(data?: Partial<APIButtonComponent>) {
super({ type: ComponentType.Button, ...data });
}
/**
* Sets the style of this button.
*
* @param style - The style to use
*/
public setStyle(style: ButtonStyle) {
this.data.style = buttonStyleValidator.parse(style);
return this;
}
/**
* Sets the URL for this button.
*
* @remarks
* This method is only available to buttons using the `Link` button style.
* Only three types of URL schemes are currently supported: `https://`, `http://`, and `discord://`.
* @param url - The URL to use
*/
public setURL(url: string) {
(this.data as APIButtonComponentWithURL).url = urlValidator.parse(url);
return this;
}
/**
* Sets the custom id for this button.
*
* @remarks
* This method is only applicable to buttons that are not using the `Link` button style.
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
(this.data as APIButtonComponentWithCustomId).custom_id = customIdValidator.parse(customId);
return this;
}
/**
* Sets the SKU id that represents a purchasable SKU for this button.
*
* @remarks Only available when using premium-style buttons.
* @param skuId - The SKU id to use
*/
public setSKUId(skuId: Snowflake) {
(this.data as APIButtonComponentWithSKUId).sku_id = skuId;
return this;
}
/**
* Sets the emoji to display on this button.
*
* @param emoji - The emoji to use
*/
public setEmoji(emoji: APIMessageComponentEmoji) {
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).emoji = emojiValidator.parse(emoji);
return this;
}
/**
* Sets whether this button is disabled.
@@ -15,17 +117,35 @@ export abstract class BaseButtonBuilder<ButtonData extends APIButtonComponent> e
* @param disabled - Whether to disable this button
*/
public setDisabled(disabled = true) {
this.data.disabled = disabled;
this.data.disabled = disabledValidator.parse(disabled);
return this;
}
/**
* Sets the label for this button.
*
* @param label - The label to use
*/
public setLabel(label: string) {
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).label = buttonLabelValidator.parse(label);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): ButtonData {
const clone = structuredClone(this.data);
validate(buttonPredicate, clone, validationOverride);
public toJSON(): APIButtonComponent {
validateRequiredButtonParameters(
this.data.style,
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).label,
(this.data as Exclude<APIButtonComponent, APIButtonComponentWithSKUId>).emoji,
(this.data as APIButtonComponentWithCustomId).custom_id,
(this.data as APIButtonComponentWithSKUId).sku_id,
(this.data as APIButtonComponentWithURL).url,
);
return clone as ButtonData;
return {
...this.data,
} as APIButtonComponent;
}
}

View File

@@ -1,69 +0,0 @@
import { ButtonStyle, ComponentType, type APIButtonComponentWithCustomId } from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { BaseButtonBuilder } from './Button.js';
import { EmojiOrLabelButtonMixin } from './mixins/EmojiOrLabelButtonMixin.js';
export type CustomIdButtonStyle = APIButtonComponentWithCustomId['style'];
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs.
*/
export abstract class CustomIdButtonBuilder extends Mixin(
BaseButtonBuilder<APIButtonComponentWithCustomId>,
EmojiOrLabelButtonMixin,
) {
protected override readonly data: Partial<APIButtonComponentWithCustomId>;
protected constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.Button };
}
/**
* Sets the custom id for this button.
*
* @remarks
* This method is only applicable to buttons that are not using the `Link` button style.
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customId;
return this;
}
}
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs (using the primary style).
*/
export class PrimaryButtonBuilder extends CustomIdButtonBuilder {
public constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super({ ...data, style: ButtonStyle.Primary });
}
}
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs (using the secondary style).
*/
export class SecondaryButtonBuilder extends CustomIdButtonBuilder {
public constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super({ ...data, style: ButtonStyle.Secondary });
}
}
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs (using the success style).
*/
export class SuccessButtonBuilder extends CustomIdButtonBuilder {
public constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super({ ...data, style: ButtonStyle.Success });
}
}
/**
* A builder that creates API-compatible JSON data for buttons with custom IDs (using the danger style).
*/
export class DangerButtonBuilder extends CustomIdButtonBuilder {
public constructor(data: Partial<APIButtonComponentWithCustomId> = {}) {
super({ ...data, style: ButtonStyle.Danger });
}
}

View File

@@ -1,34 +0,0 @@
import {
ButtonStyle,
ComponentType,
type APIButtonComponent,
type APIButtonComponentWithURL,
} from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { BaseButtonBuilder } from './Button.js';
import { EmojiOrLabelButtonMixin } from './mixins/EmojiOrLabelButtonMixin.js';
/**
* A builder that creates API-compatible JSON data for buttons with links.
*/
export class LinkButtonBuilder extends Mixin(BaseButtonBuilder<APIButtonComponentWithURL>, EmojiOrLabelButtonMixin) {
protected override readonly data: Partial<APIButtonComponentWithURL>;
public constructor(data: Partial<APIButtonComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.Button, style: ButtonStyle.Link };
}
/**
* Sets the URL for this button.
*
* @remarks
* This method is only available to buttons using the `Link` button style.
* Only three types of URL schemes are currently supported: `https://`, `http://`, and `discord://`.
* @param url - The URL to use
*/
public setURL(url: string) {
this.data.url = url;
return this;
}
}

View File

@@ -1,26 +0,0 @@
import type { APIButtonComponentWithSKUId, Snowflake } from 'discord-api-types/v10';
import { ButtonStyle, ComponentType } from 'discord-api-types/v10';
import { BaseButtonBuilder } from './Button.js';
/**
* A builder that creates API-compatible JSON data for premium buttons.
*/
export class PremiumButtonBuilder extends BaseButtonBuilder<APIButtonComponentWithSKUId> {
protected override readonly data: Partial<APIButtonComponentWithSKUId>;
public constructor(data: Partial<APIButtonComponentWithSKUId> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.Button, style: ButtonStyle.Premium };
}
/**
* Sets the SKU id that represents a purchasable SKU for this button.
*
* @remarks Only available when using premium-style buttons.
* @param skuId - The SKU id to use
*/
public setSKUId(skuId: Snowflake) {
this.data.sku_id = skuId;
return this;
}
}

View File

@@ -1,44 +0,0 @@
import type { APIButtonComponent, APIButtonComponentWithSKUId, APIMessageComponentEmoji } from 'discord-api-types/v10';
export interface EmojiOrLabelButtonData
extends Pick<Exclude<APIButtonComponent, APIButtonComponentWithSKUId>, 'emoji' | 'label'> {}
export class EmojiOrLabelButtonMixin {
protected declare readonly data: EmojiOrLabelButtonData;
/**
* Sets the emoji to display on this button.
*
* @param emoji - The emoji to use
*/
public setEmoji(emoji: APIMessageComponentEmoji) {
this.data.emoji = emoji;
return this;
}
/**
* Clears the emoji on this button.
*/
public clearEmoji() {
this.data.emoji = undefined;
return this;
}
/**
* Sets the label for this button.
*
* @param label - The label to use
*/
public setLabel(label: string) {
this.data.label = label;
return this;
}
/**
* Clears the label on this button.
*/
public clearLabel() {
this.data.label = undefined;
return this;
}
}

View File

@@ -1,5 +1,5 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APISelectMenuComponent } from 'discord-api-types/v10';
import { customIdValidator, disabledValidator, minMaxValidator, placeholderValidator } from '../Assertions.js';
import { ComponentBuilder } from '../Component.js';
/**
@@ -7,29 +7,16 @@ import { ComponentBuilder } from '../Component.js';
*
* @typeParam SelectMenuType - The type of select menu this would be instantiated for.
*/
export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
extends ComponentBuilder<Data>
implements JSONEncodable<APISelectMenuComponent>
{
protected abstract readonly data: Partial<
Pick<Data, 'custom_id' | 'disabled' | 'max_values' | 'min_values' | 'placeholder'>
>;
export abstract class BaseSelectMenuBuilder<
SelectMenuType extends APISelectMenuComponent,
> extends ComponentBuilder<SelectMenuType> {
/**
* Sets the placeholder for this select menu.
*
* @param placeholder - The placeholder to use
*/
public setPlaceholder(placeholder: string) {
this.data.placeholder = placeholder;
return this;
}
/**
* Clears the placeholder for this select menu.
*/
public clearPlaceholder() {
this.data.placeholder = undefined;
this.data.placeholder = placeholderValidator.parse(placeholder);
return this;
}
@@ -39,7 +26,7 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
* @param minValues - The minimum values that must be selected
*/
public setMinValues(minValues: number) {
this.data.min_values = minValues;
this.data.min_values = minMaxValidator.parse(minValues);
return this;
}
@@ -49,7 +36,7 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
* @param maxValues - The maximum values that must be selected
*/
public setMaxValues(maxValues: number) {
this.data.max_values = maxValues;
this.data.max_values = minMaxValidator.parse(maxValues);
return this;
}
@@ -59,7 +46,7 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customId;
this.data.custom_id = customIdValidator.parse(customId);
return this;
}
@@ -69,7 +56,17 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
* @param disabled - Whether this select menu is disabled
*/
public setDisabled(disabled = true) {
this.data.disabled = disabled;
this.data.disabled = disabledValidator.parse(disabled);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): SelectMenuType {
customIdValidator.parse(this.data.custom_id);
return {
...this.data,
} as SelectMenuType;
}
}

View File

@@ -6,16 +6,13 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { validate } from '../../util/validation.js';
import { selectMenuChannelPredicate } from '../Assertions.js';
import { channelTypesValidator, customIdValidator, optionsLengthValidator } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
/**
* A builder that creates API-compatible JSON data for channel select menus.
*/
export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSelectComponent> {
protected override readonly data: Partial<APIChannelSelectComponent>;
/**
* Creates a new select menu from API data.
*
@@ -39,9 +36,8 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
* .setMinValues(2);
* ```
*/
public constructor(data: Partial<APIChannelSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.ChannelSelect };
public constructor(data?: Partial<APIChannelSelectComponent>) {
super({ ...data, type: ComponentType.ChannelSelect });
}
/**
@@ -52,7 +48,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
public addChannelTypes(...types: RestOrArray<ChannelType>) {
const normalizedTypes = normalizeArray(types);
this.data.channel_types ??= [];
this.data.channel_types.push(...normalizedTypes);
this.data.channel_types.push(...channelTypesValidator.parse(normalizedTypes));
return this;
}
@@ -64,7 +60,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
public setChannelTypes(...types: RestOrArray<ChannelType>) {
const normalizedTypes = normalizeArray(types);
this.data.channel_types ??= [];
this.data.channel_types.splice(0, this.data.channel_types.length, ...normalizedTypes);
this.data.channel_types.splice(0, this.data.channel_types.length, ...channelTypesValidator.parse(normalizedTypes));
return this;
}
@@ -75,6 +71,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
*/
public addDefaultChannels(...channels: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(channels);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(
@@ -94,6 +91,7 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
*/
public setDefaultChannels(...channels: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(channels);
optionsLengthValidator.parse(normalizedValues.length);
this.data.default_values = normalizedValues.map((id) => ({
id,
@@ -104,12 +102,13 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
* {@inheritDoc BaseSelectMenuBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIChannelSelectComponent {
const clone = structuredClone(this.data);
validate(selectMenuChannelPredicate, clone, validationOverride);
public override toJSON(): APIChannelSelectComponent {
customIdValidator.parse(this.data.custom_id);
return clone as APIChannelSelectComponent;
return {
...this.data,
} as APIChannelSelectComponent;
}
}

View File

@@ -6,16 +6,13 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { validate } from '../../util/validation.js';
import { selectMenuMentionablePredicate } from '../Assertions.js';
import { optionsLengthValidator } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
/**
* A builder that creates API-compatible JSON data for mentionable select menus.
*/
export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMentionableSelectComponent> {
protected override readonly data: Partial<APIMentionableSelectComponent>;
/**
* Creates a new select menu from API data.
*
@@ -38,9 +35,8 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
* .setMinValues(1);
* ```
*/
public constructor(data: Partial<APIMentionableSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.MentionableSelect };
public constructor(data?: Partial<APIMentionableSelectComponent>) {
super({ ...data, type: ComponentType.MentionableSelect });
}
/**
@@ -50,6 +46,7 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
*/
public addDefaultRoles(...roles: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(roles);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(
@@ -69,6 +66,7 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
*/
public addDefaultUsers(...users: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(users);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(
@@ -93,6 +91,7 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
>
) {
const normalizedValues = normalizeArray(values);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(...normalizedValues);
return this;
@@ -110,17 +109,8 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
>
) {
const normalizedValues = normalizeArray(values);
optionsLengthValidator.parse(normalizedValues.length);
this.data.default_values = normalizedValues;
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIMentionableSelectComponent {
const clone = structuredClone(this.data);
validate(selectMenuMentionablePredicate, clone, validationOverride);
return clone as APIMentionableSelectComponent;
}
}

View File

@@ -5,16 +5,13 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { validate } from '../../util/validation.js';
import { selectMenuRolePredicate } from '../Assertions.js';
import { optionsLengthValidator } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
/**
* A builder that creates API-compatible JSON data for role select menus.
*/
export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectComponent> {
protected override readonly data: Partial<APIRoleSelectComponent>;
/**
* Creates a new select menu from API data.
*
@@ -37,9 +34,8 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectCo
* .setMinValues(1);
* ```
*/
public constructor(data: Partial<APIRoleSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.RoleSelect };
public constructor(data?: Partial<APIRoleSelectComponent>) {
super({ ...data, type: ComponentType.RoleSelect });
}
/**
@@ -49,6 +45,7 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectCo
*/
public addDefaultRoles(...roles: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(roles);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(
@@ -68,6 +65,7 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectCo
*/
public setDefaultRoles(...roles: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(roles);
optionsLengthValidator.parse(normalizedValues.length);
this.data.default_values = normalizedValues.map((id) => ({
id,
@@ -76,14 +74,4 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectCo
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIRoleSelectComponent {
const clone = structuredClone(this.data);
validate(selectMenuRolePredicate, clone, validationOverride);
return clone as APIRoleSelectComponent;
}
}

View File

@@ -1,30 +1,18 @@
/* eslint-disable jsdoc/check-param-names */
import { ComponentType } from 'discord-api-types/v10';
import type { APIStringSelectComponent, APISelectMenuOption } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import { resolveBuilder } from '../../util/resolveBuilder.js';
import { validate } from '../../util/validation.js';
import { selectMenuStringPredicate } from '../Assertions.js';
import { jsonOptionValidator, optionsLengthValidator, validateRequiredSelectMenuParameters } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
import { StringSelectMenuOptionBuilder } from './StringSelectMenuOption.js';
export interface StringSelectMenuData extends Partial<Omit<APIStringSelectComponent, 'options'>> {
options: StringSelectMenuOptionBuilder[];
}
/**
* A builder that creates API-compatible JSON data for string select menus.
*/
export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSelectComponent> {
protected override readonly data: StringSelectMenuData;
/**
* The options for this select menu.
* The options within this select menu.
*/
public get options(): readonly StringSelectMenuOptionBuilder[] {
return this.data.options;
}
public readonly options: StringSelectMenuOptionBuilder[];
/**
* Creates a new select menu from API data.
@@ -57,13 +45,10 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
* });
* ```
*/
public constructor({ options = [], ...data }: Partial<APIStringSelectComponent> = {}) {
super();
this.data = {
...structuredClone(data),
options: options.map((option) => new StringSelectMenuOptionBuilder(option)),
type: ComponentType.StringSelect,
};
public constructor(data?: Partial<APIStringSelectComponent>) {
const { options, ...initData } = data ?? {};
super({ ...initData, type: ComponentType.StringSelect });
this.options = options?.map((option: APISelectMenuOption) => new StringSelectMenuOptionBuilder(option)) ?? [];
}
/**
@@ -71,18 +56,16 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
*
* @param options - The options to add
*/
public addOptions(
...options: RestOrArray<
| APISelectMenuOption
| StringSelectMenuOptionBuilder
| ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder)
>
) {
public addOptions(...options: RestOrArray<APISelectMenuOption | StringSelectMenuOptionBuilder>) {
const normalizedOptions = normalizeArray(options);
const resolved = normalizedOptions.map((option) => resolveBuilder(option, StringSelectMenuOptionBuilder));
this.data.options.push(...resolved);
optionsLengthValidator.parse(this.options.length + normalizedOptions.length);
this.options.push(
...normalizedOptions.map((normalizedOption) =>
normalizedOption instanceof StringSelectMenuOptionBuilder
? normalizedOption
: new StringSelectMenuOptionBuilder(jsonOptionValidator.parse(normalizedOption)),
),
);
return this;
}
@@ -91,14 +74,8 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
*
* @param options - The options to set
*/
public setOptions(
...options: RestOrArray<
| APISelectMenuOption
| StringSelectMenuOptionBuilder
| ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder)
>
) {
return this.spliceOptions(0, this.options.length, ...normalizeArray(options));
public setOptions(...options: RestOrArray<APISelectMenuOption | StringSelectMenuOptionBuilder>) {
return this.spliceOptions(0, this.options.length, ...options);
}
/**
@@ -106,7 +83,7 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
*
* @remarks
* This method behaves similarly
* to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/slice | Array.prototype.splice()}.
* to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/slice | Array.prototype.splice()}.
* It's useful for modifying and adjusting the order of existing options.
* @example
* Remove the first option:
@@ -131,33 +108,36 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
public spliceOptions(
index: number,
deleteCount: number,
...options: (
| APISelectMenuOption
| StringSelectMenuOptionBuilder
| ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder)
)[]
...options: RestOrArray<APISelectMenuOption | StringSelectMenuOptionBuilder>
) {
const resolved = options.map((option) => resolveBuilder(option, StringSelectMenuOptionBuilder));
const normalizedOptions = normalizeArray(options);
this.data.options ??= [];
this.data.options.splice(index, deleteCount, ...resolved);
const clone = [...this.options];
clone.splice(
index,
deleteCount,
...normalizedOptions.map((normalizedOption) =>
normalizedOption instanceof StringSelectMenuOptionBuilder
? normalizedOption
: new StringSelectMenuOptionBuilder(jsonOptionValidator.parse(normalizedOption)),
),
);
optionsLengthValidator.parse(clone.length);
this.options.splice(0, this.options.length, ...clone);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
* {@inheritDoc BaseSelectMenuBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIStringSelectComponent {
const { options, ...rest } = this.data;
const data = {
...(structuredClone(rest) as APIStringSelectComponent),
// selectMenuStringPredicate covers the validation of options
options: options.map((option) => option.toJSON(false)),
};
public override toJSON(): APIStringSelectComponent {
validateRequiredSelectMenuParameters(this.options, this.data.custom_id);
validate(selectMenuStringPredicate, data, validationOverride);
return data as APIStringSelectComponent;
return {
...this.data,
options: this.options.map((option) => option.toJSON()),
} as APIStringSelectComponent;
}
}

View File

@@ -1,14 +1,16 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIMessageComponentEmoji, APISelectMenuOption } from 'discord-api-types/v10';
import { validate } from '../../util/validation.js';
import { selectMenuStringOptionPredicate } from '../Assertions.js';
import {
defaultValidator,
emojiValidator,
labelValueDescriptionValidator,
validateRequiredSelectMenuOptionParameters,
} from '../Assertions.js';
/**
* A builder that creates API-compatible JSON data for string select menu options.
*/
export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMenuOption> {
private readonly data: Partial<APISelectMenuOption>;
/**
* Creates a new string select menu option from API data.
*
@@ -31,9 +33,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* .setLabel('woah');
* ```
*/
public constructor(data: Partial<APISelectMenuOption> = {}) {
this.data = structuredClone(data);
}
public constructor(public data: Partial<APISelectMenuOption> = {}) {}
/**
* Sets the label for this option.
@@ -41,7 +41,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param label - The label to use
*/
public setLabel(label: string) {
this.data.label = label;
this.data.label = labelValueDescriptionValidator.parse(label);
return this;
}
@@ -51,7 +51,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param value - The value to use
*/
public setValue(value: string) {
this.data.value = value;
this.data.value = labelValueDescriptionValidator.parse(value);
return this;
}
@@ -61,15 +61,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param description - The description to use
*/
public setDescription(description: string) {
this.data.description = description;
return this;
}
/**
* Clears the description for this option.
*/
public clearDescription() {
this.data.description = undefined;
this.data.description = labelValueDescriptionValidator.parse(description);
return this;
}
@@ -79,7 +71,7 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param isDefault - Whether this option is selected by default
*/
public setDefault(isDefault = true) {
this.data.default = isDefault;
this.data.default = defaultValidator.parse(isDefault);
return this;
}
@@ -89,25 +81,18 @@ export class StringSelectMenuOptionBuilder implements JSONEncodable<APISelectMen
* @param emoji - The emoji to use
*/
public setEmoji(emoji: APIMessageComponentEmoji) {
this.data.emoji = emoji;
this.data.emoji = emojiValidator.parse(emoji);
return this;
}
/**
* Clears the emoji for this option.
* {@inheritDoc BaseSelectMenuBuilder.toJSON}
*/
public clearEmoji() {
this.data.emoji = undefined;
return this;
}
public toJSON(): APISelectMenuOption {
validateRequiredSelectMenuOptionParameters(this.data.label, this.data.value);
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(validationOverride?: boolean): APISelectMenuOption {
const clone = structuredClone(this.data);
validate(selectMenuStringOptionPredicate, clone, validationOverride);
return clone as APISelectMenuOption;
return {
...this.data,
} as APISelectMenuOption;
}
}

View File

@@ -5,16 +5,13 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { validate } from '../../util/validation.js';
import { selectMenuUserPredicate } from '../Assertions.js';
import { optionsLengthValidator } from '../Assertions.js';
import { BaseSelectMenuBuilder } from './BaseSelectMenu.js';
/**
* A builder that creates API-compatible JSON data for user select menus.
*/
export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectComponent> {
protected override readonly data: Partial<APIUserSelectComponent>;
/**
* Creates a new select menu from API data.
*
@@ -37,9 +34,8 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectCo
* .setMinValues(1);
* ```
*/
public constructor(data: Partial<APIUserSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.UserSelect };
public constructor(data?: Partial<APIUserSelectComponent>) {
super({ ...data, type: ComponentType.UserSelect });
}
/**
@@ -49,8 +45,9 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectCo
*/
public addDefaultUsers(...users: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(users);
optionsLengthValidator.parse((this.data.default_values?.length ?? 0) + normalizedValues.length);
this.data.default_values ??= [];
this.data.default_values.push(
...normalizedValues.map((id) => ({
id,
@@ -68,6 +65,7 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectCo
*/
public setDefaultUsers(...users: RestOrArray<Snowflake>) {
const normalizedValues = normalizeArray(users);
optionsLengthValidator.parse(normalizedValues.length);
this.data.default_values = normalizedValues.map((id) => ({
id,
@@ -76,14 +74,4 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectCo
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APIUserSelectComponent {
const clone = structuredClone(this.data);
validate(selectMenuUserPredicate, clone, validationOverride);
return clone as APIUserSelectComponent;
}
}

View File

@@ -1,15 +1,32 @@
import { ComponentType, TextInputStyle } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate } from '../../Assertions.js';
import { s } from '@sapphire/shapeshift';
import { TextInputStyle } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { customIdValidator } from '../Assertions.js';
export const textInputPredicate = z.object({
type: z.literal(ComponentType.TextInput),
custom_id: customIdPredicate,
label: z.string().min(1).max(45),
style: z.nativeEnum(TextInputStyle),
min_length: z.number().min(0).max(4_000).optional(),
max_length: z.number().min(1).max(4_000).optional(),
placeholder: z.string().max(100).optional(),
value: z.string().max(4_000).optional(),
required: z.boolean().optional(),
});
export const textInputStyleValidator = s.nativeEnum(TextInputStyle);
export const minLengthValidator = s
.number()
.int()
.greaterThanOrEqual(0)
.lessThanOrEqual(4_000)
.setValidationEnabled(isValidationEnabled);
export const maxLengthValidator = s
.number()
.int()
.greaterThanOrEqual(1)
.lessThanOrEqual(4_000)
.setValidationEnabled(isValidationEnabled);
export const requiredValidator = s.boolean();
export const valueValidator = s.string().lengthLessThanOrEqual(4_000).setValidationEnabled(isValidationEnabled);
export const placeholderValidator = s.string().lengthLessThanOrEqual(100).setValidationEnabled(isValidationEnabled);
export const labelValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(45)
.setValidationEnabled(isValidationEnabled);
export function validateRequiredParameters(customId?: string, style?: TextInputStyle, label?: string) {
customIdValidator.parse(customId);
textInputStyleValidator.parse(style);
labelValidator.parse(label);
}

View File

@@ -1,14 +1,26 @@
import { isJSONEncodable, type Equatable, type JSONEncodable } from '@discordjs/util';
import { ComponentType, type TextInputStyle, type APITextInputComponent } from 'discord-api-types/v10';
import { validate } from '../../util/validation.js';
import isEqual from 'fast-deep-equal';
import { customIdValidator } from '../Assertions.js';
import { ComponentBuilder } from '../Component.js';
import { textInputPredicate } from './Assertions.js';
import {
maxLengthValidator,
minLengthValidator,
placeholderValidator,
requiredValidator,
valueValidator,
validateRequiredParameters,
labelValidator,
textInputStyleValidator,
} from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for text inputs.
*/
export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
private readonly data: Partial<APITextInputComponent>;
export class TextInputBuilder
extends ComponentBuilder<APITextInputComponent>
implements Equatable<APITextInputComponent | JSONEncodable<APITextInputComponent>>
{
/**
* Creates a new text input from API data.
*
@@ -32,9 +44,8 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* .setStyle(TextInputStyle.Paragraph);
* ```
*/
public constructor(data: Partial<APITextInputComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.TextInput };
public constructor(data?: APITextInputComponent & { type?: ComponentType.TextInput }) {
super({ type: ComponentType.TextInput, ...data });
}
/**
@@ -43,7 +54,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customId;
this.data.custom_id = customIdValidator.parse(customId);
return this;
}
@@ -53,7 +64,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* @param label - The label to use
*/
public setLabel(label: string) {
this.data.label = label;
this.data.label = labelValidator.parse(label);
return this;
}
@@ -63,7 +74,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* @param style - The style to use
*/
public setStyle(style: TextInputStyle) {
this.data.style = style;
this.data.style = textInputStyleValidator.parse(style);
return this;
}
@@ -73,15 +84,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* @param minLength - The minimum length of text for this text input
*/
public setMinLength(minLength: number) {
this.data.min_length = minLength;
return this;
}
/**
* Clears the minimum length of text for this text input.
*/
public clearMinLength() {
this.data.min_length = undefined;
this.data.min_length = minLengthValidator.parse(minLength);
return this;
}
@@ -91,15 +94,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* @param maxLength - The maximum length of text for this text input
*/
public setMaxLength(maxLength: number) {
this.data.max_length = maxLength;
return this;
}
/**
* Clears the maximum length of text for this text input.
*/
public clearMaxLength() {
this.data.max_length = undefined;
this.data.max_length = maxLengthValidator.parse(maxLength);
return this;
}
@@ -109,15 +104,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* @param placeholder - The placeholder to use
*/
public setPlaceholder(placeholder: string) {
this.data.placeholder = placeholder;
return this;
}
/**
* Clears the placeholder for this text input.
*/
public clearPlaceholder() {
this.data.placeholder = undefined;
this.data.placeholder = placeholderValidator.parse(placeholder);
return this;
}
@@ -127,15 +114,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* @param value - The value to use
*/
public setValue(value: string) {
this.data.value = value;
return this;
}
/**
* Clears the value for this text input.
*/
public clearValue() {
this.data.value = undefined;
this.data.value = valueValidator.parse(value);
return this;
}
@@ -145,17 +124,29 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* @param required - Whether this text input is required
*/
public setRequired(required = true) {
this.data.required = required;
this.data.required = requiredValidator.parse(required);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(validationOverride?: boolean): APITextInputComponent {
const clone = structuredClone(this.data);
validate(textInputPredicate, clone, validationOverride);
public toJSON(): APITextInputComponent {
validateRequiredParameters(this.data.custom_id, this.data.style, this.data.label);
return clone as APITextInputComponent;
return {
...this.data,
} as APITextInputComponent;
}
/**
* Whether this is equal to another structure.
*/
public equals(other: APITextInputComponent | JSONEncodable<APITextInputComponent>): boolean {
if (isJSONEncodable(other)) {
return isEqual(other.toJSON(), this.data);
}
return isEqual(other, this.data);
}
}

View File

@@ -1,71 +1,68 @@
export * from './components/button/mixins/EmojiOrLabelButtonMixin.js';
export * as EmbedAssertions from './messages/embed/Assertions.js';
export * from './messages/embed/Embed.js';
// TODO: Consider removing this dep in the next major version
export * from '@discordjs/formatters';
export * as ComponentAssertions from './components/Assertions.js';
export * from './components/ActionRow.js';
export * from './components/button/Button.js';
export * from './components/button/CustomIdButton.js';
export * from './components/button/LinkButton.js';
export * from './components/button/PremiumButton.js';
export * from './components/Component.js';
export * from './components/Components.js';
export * from './components/textInput/TextInput.js';
export * as TextInputAssertions from './components/textInput/Assertions.js';
export * from './interactions/modals/Modal.js';
export * as ModalAssertions from './interactions/modals/Assertions.js';
export * from './components/selectMenu/BaseSelectMenu.js';
export * from './components/selectMenu/ChannelSelectMenu.js';
export * from './components/selectMenu/MentionableSelectMenu.js';
export * from './components/selectMenu/RoleSelectMenu.js';
export * from './components/selectMenu/StringSelectMenu.js';
// TODO: Remove those aliases in v2
export {
/**
* @deprecated Will be removed in the next major version, use {@link StringSelectMenuBuilder} instead.
*/
StringSelectMenuBuilder as SelectMenuBuilder,
} from './components/selectMenu/StringSelectMenu.js';
export {
/**
* @deprecated Will be removed in the next major version, use {@link StringSelectMenuOptionBuilder} instead.
*/
StringSelectMenuOptionBuilder as SelectMenuOptionBuilder,
} from './components/selectMenu/StringSelectMenuOption.js';
export * from './components/selectMenu/StringSelectMenuOption.js';
export * from './components/selectMenu/UserSelectMenu.js';
export * from './components/textInput/TextInput.js';
export * from './components/textInput/Assertions.js';
export * as SlashCommandAssertions from './interactions/slashCommands/Assertions.js';
export * from './interactions/slashCommands/SlashCommandBuilder.js';
export * from './interactions/slashCommands/SlashCommandSubcommands.js';
export * from './interactions/slashCommands/options/boolean.js';
export * from './interactions/slashCommands/options/channel.js';
export * from './interactions/slashCommands/options/integer.js';
export * from './interactions/slashCommands/options/mentionable.js';
export * from './interactions/slashCommands/options/number.js';
export * from './interactions/slashCommands/options/role.js';
export * from './interactions/slashCommands/options/attachment.js';
export * from './interactions/slashCommands/options/string.js';
export * from './interactions/slashCommands/options/user.js';
export * from './interactions/slashCommands/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js';
export * from './interactions/slashCommands/mixins/ApplicationCommandOptionBase.js';
export * from './interactions/slashCommands/mixins/ApplicationCommandOptionChannelTypesMixin.js';
export * from './interactions/slashCommands/mixins/ApplicationCommandOptionWithAutocompleteMixin.js';
export * from './interactions/slashCommands/mixins/ApplicationCommandOptionWithChoicesMixin.js';
export * from './interactions/slashCommands/mixins/NameAndDescription.js';
export * from './interactions/slashCommands/mixins/SharedSlashCommandOptions.js';
export * from './interactions/slashCommands/mixins/SharedSubcommands.js';
export * from './interactions/slashCommands/mixins/SharedSlashCommand.js';
export * from './components/ActionRow.js';
export * from './components/Assertions.js';
export * from './components/Component.js';
export * from './components/Components.js';
export * from './interactions/commands/chatInput/mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js';
export * from './interactions/commands/chatInput/mixins/ApplicationCommandOptionChannelTypesMixin.js';
export * from './interactions/commands/chatInput/mixins/ApplicationCommandOptionWithAutocompleteMixin.js';
export * from './interactions/commands/chatInput/mixins/ApplicationCommandOptionWithChoicesMixin.js';
export * from './interactions/commands/chatInput/mixins/SharedChatInputCommandOptions.js';
export * from './interactions/commands/chatInput/mixins/SharedSubcommands.js';
export * from './interactions/commands/chatInput/options/ApplicationCommandOptionBase.js';
export * from './interactions/commands/chatInput/options/boolean.js';
export * from './interactions/commands/chatInput/options/channel.js';
export * from './interactions/commands/chatInput/options/integer.js';
export * from './interactions/commands/chatInput/options/mentionable.js';
export * from './interactions/commands/chatInput/options/number.js';
export * from './interactions/commands/chatInput/options/role.js';
export * from './interactions/commands/chatInput/options/attachment.js';
export * from './interactions/commands/chatInput/options/string.js';
export * from './interactions/commands/chatInput/options/user.js';
export * from './interactions/commands/chatInput/Assertions.js';
export * from './interactions/commands/chatInput/ChatInputCommand.js';
export * from './interactions/commands/chatInput/ChatInputCommandSubcommands.js';
export * from './interactions/commands/contextMenu/Assertions.js';
export * from './interactions/commands/contextMenu/ContextMenuCommand.js';
export * from './interactions/commands/contextMenu/MessageCommand.js';
export * from './interactions/commands/contextMenu/UserCommand.js';
export * from './interactions/commands/Command.js';
export * from './interactions/commands/SharedName.js';
export * from './interactions/commands/SharedNameAndDescription.js';
export * from './interactions/modals/Assertions.js';
export * from './interactions/modals/Modal.js';
export * from './messages/embed/Assertions.js';
export * from './messages/embed/Embed.js';
export * from './messages/embed/EmbedAuthor.js';
export * from './messages/embed/EmbedField.js';
export * from './messages/embed/EmbedFooter.js';
export * as ContextMenuCommandAssertions from './interactions/contextMenuCommands/Assertions.js';
export * from './interactions/contextMenuCommands/ContextMenuCommandBuilder.js';
export * from './util/componentUtil.js';
export * from './util/normalizeArray.js';
export * from './util/validation.js';
export * from './Assertions.js';
/**
* The {@link https://github.com/discordjs/discord.js/blob/main/packages/builders#readme | @discordjs/builders} version
* that you are currently using.

View File

@@ -1,83 +0,0 @@
import type { JSONEncodable } from '@discordjs/util';
import type {
ApplicationIntegrationType,
InteractionContextType,
Permissions,
RESTPostAPIApplicationCommandsJSONBody,
} from 'discord-api-types/v10';
import type { RestOrArray } from '../../util/normalizeArray.js';
import { normalizeArray } from '../../util/normalizeArray.js';
export interface CommandData
extends Partial<
Pick<
RESTPostAPIApplicationCommandsJSONBody,
'contexts' | 'default_member_permissions' | 'integration_types' | 'nsfw'
>
> {}
export abstract class CommandBuilder<Command extends RESTPostAPIApplicationCommandsJSONBody>
implements JSONEncodable<Command>
{
protected declare readonly data: CommandData;
/**
* Sets the contexts of this command.
*
* @param contexts - The contexts
*/
public setContexts(...contexts: RestOrArray<InteractionContextType>) {
this.data.contexts = normalizeArray(contexts);
return this;
}
/**
* Sets the integration types of this command.
*
* @param integrationTypes - The integration types
*/
public setIntegrationTypes(...integrationTypes: RestOrArray<ApplicationIntegrationType>) {
this.data.integration_types = normalizeArray(integrationTypes);
return this;
}
/**
* Sets the default permissions a member should have in order to run the command.
*
* @remarks
* You can set this to `'0'` to disable the command by default.
* @param permissions - The permissions bit field to set
* @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions}
*/
public setDefaultMemberPermissions(permissions: Permissions | bigint | number) {
this.data.default_member_permissions = typeof permissions === 'string' ? permissions : permissions.toString();
return this;
}
/**
* Clears the default permissions a member should have in order to run the command.
*/
public clearDefaultMemberPermissions() {
this.data.default_member_permissions = undefined;
return this;
}
/**
* Sets whether this command is NSFW.
*
* @param nsfw - Whether this command is NSFW
*/
public setNSFW(nsfw = true) {
this.data.nsfw = nsfw;
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public abstract toJSON(validationOverride?: boolean): Command;
}

View File

@@ -1,64 +0,0 @@
import type { LocaleString, RESTPostAPIApplicationCommandsJSONBody } from 'discord-api-types/v10';
export interface SharedNameData
extends Partial<Pick<RESTPostAPIApplicationCommandsJSONBody, 'name_localizations' | 'name'>> {}
/**
* This mixin holds name and description symbols for chat input commands.
*/
export class SharedName {
protected readonly data: SharedNameData = {};
/**
* Sets the name of this command.
*
* @param name - The name to use
*/
public setName(name: string): this {
this.data.name = name;
return this;
}
/**
* Sets a name localization for this command.
*
* @param locale - The locale to set
* @param localizedName - The localized name for the given `locale`
*/
public setNameLocalization(locale: LocaleString, localizedName: string) {
this.data.name_localizations ??= {};
this.data.name_localizations[locale] = localizedName;
return this;
}
/**
* Clears a name localization for this command.
*
* @param locale - The locale to clear
*/
public clearNameLocalization(locale: LocaleString) {
this.data.name_localizations ??= {};
this.data.name_localizations[locale] = undefined;
return this;
}
/**
* Sets the name localizations for this command.
*
* @param localizedNames - The object of localized names to set
*/
public setNameLocalizations(localizedNames: Partial<Record<LocaleString, string>>) {
this.data.name_localizations = structuredClone(localizedNames);
return this;
}
/**
* Clears all name localizations for this command.
*/
public clearNameLocalizations() {
this.data.name_localizations = undefined;
return this;
}
}

View File

@@ -1,67 +0,0 @@
import type { APIApplicationCommand, LocaleString } from 'discord-api-types/v10';
import type { SharedNameData } from './SharedName.js';
import { SharedName } from './SharedName.js';
export interface SharedNameAndDescriptionData
extends SharedNameData,
Partial<Pick<APIApplicationCommand, 'description_localizations' | 'description'>> {}
/**
* This mixin holds name and description symbols for chat input commands.
*/
export class SharedNameAndDescription extends SharedName {
protected override readonly data: SharedNameAndDescriptionData = {};
/**
* Sets the description of this command.
*
* @param description - The description to use
*/
public setDescription(description: string) {
this.data.description = description;
return this;
}
/**
* Sets a description localization for this command.
*
* @param locale - The locale to set
* @param localizedDescription - The localized description for the given `locale`
*/
public setDescriptionLocalization(locale: LocaleString, localizedDescription: string) {
this.data.description_localizations ??= {};
this.data.description_localizations[locale] = localizedDescription;
return this;
}
/**
* Clears a description localization for this command.
*
* @param locale - The locale to clear
*/
public clearDescriptionLocalization(locale: LocaleString) {
this.data.description_localizations ??= {};
this.data.description_localizations[locale] = undefined;
return this;
}
/**
* Sets the description localizations for this command.
*
* @param localizedDescriptions - The object of localized descriptions to set
*/
public setDescriptionLocalizations(localizedDescriptions: Partial<Record<LocaleString, string>>) {
this.data.description_localizations = structuredClone(localizedDescriptions);
return this;
}
/**
* Clears all description localizations for this command.
*/
public clearDescriptionLocalizations() {
this.data.description_localizations = undefined;
return this;
}
}

View File

@@ -1,154 +0,0 @@
import {
ApplicationIntegrationType,
InteractionContextType,
ApplicationCommandOptionType,
} from 'discord-api-types/v10';
import type { ZodTypeAny } from 'zod';
import { z } from 'zod';
import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js';
import { ApplicationCommandOptionAllowedChannelTypes } from './mixins/ApplicationCommandOptionChannelTypesMixin.js';
const namePredicate = z
.string()
.min(1)
.max(32)
.regex(/^[\p{Ll}\p{Lm}\p{Lo}\p{N}\p{sc=Devanagari}\p{sc=Thai}_-]+$/u);
const descriptionPredicate = z.string().min(1).max(100);
const sharedNameAndDescriptionPredicate = z.object({
name: namePredicate,
name_localizations: localeMapPredicate.optional(),
description: descriptionPredicate,
description_localizations: localeMapPredicate.optional(),
});
const numericMixinNumberOptionPredicate = z.object({
max_value: z.number().safe().optional(),
min_value: z.number().safe().optional(),
});
const numericMixinIntegerOptionPredicate = z.object({
max_value: z.number().safe().int().optional(),
min_value: z.number().safe().int().optional(),
});
const channelMixinOptionPredicate = z.object({
channel_types: z
.union(
ApplicationCommandOptionAllowedChannelTypes.map((type) => z.literal(type)) as unknown as [
ZodTypeAny,
ZodTypeAny,
...ZodTypeAny[],
],
)
.array()
.optional(),
});
const autocompleteMixinOptionPredicate = z.object({
autocomplete: z.literal(true),
choices: z.union([z.never(), z.never().array(), z.undefined()]),
});
const choiceValueStringPredicate = z.string().min(1).max(100);
const choiceValueNumberPredicate = z.number().safe();
const choiceBasePredicate = z.object({
name: choiceValueStringPredicate,
name_localizations: localeMapPredicate.optional(),
});
const choiceStringPredicate = choiceBasePredicate.extend({
value: choiceValueStringPredicate,
});
const choiceNumberPredicate = choiceBasePredicate.extend({
value: choiceValueNumberPredicate,
});
const choiceBaseMixinPredicate = z.object({
autocomplete: z.literal(false).optional(),
});
const choiceStringMixinPredicate = choiceBaseMixinPredicate.extend({
choices: choiceStringPredicate.array().max(25).optional(),
});
const choiceNumberMixinPredicate = choiceBaseMixinPredicate.extend({
choices: choiceNumberPredicate.array().max(25).optional(),
});
const basicOptionTypes = [
ApplicationCommandOptionType.Attachment,
ApplicationCommandOptionType.Boolean,
ApplicationCommandOptionType.Channel,
ApplicationCommandOptionType.Integer,
ApplicationCommandOptionType.Mentionable,
ApplicationCommandOptionType.Number,
ApplicationCommandOptionType.Role,
ApplicationCommandOptionType.String,
ApplicationCommandOptionType.User,
] as const;
const basicOptionTypesPredicate = z.union(
basicOptionTypes.map((type) => z.literal(type)) as unknown as [ZodTypeAny, ZodTypeAny, ...ZodTypeAny[]],
);
export const basicOptionPredicate = sharedNameAndDescriptionPredicate.extend({
required: z.boolean().optional(),
type: basicOptionTypesPredicate,
});
const autocompleteOrStringChoicesMixinOptionPredicate = z.discriminatedUnion('autocomplete', [
autocompleteMixinOptionPredicate,
choiceStringMixinPredicate,
]);
const autocompleteOrNumberChoicesMixinOptionPredicate = z.discriminatedUnion('autocomplete', [
autocompleteMixinOptionPredicate,
choiceNumberMixinPredicate,
]);
export const channelOptionPredicate = basicOptionPredicate.merge(channelMixinOptionPredicate);
export const integerOptionPredicate = basicOptionPredicate
.merge(numericMixinIntegerOptionPredicate)
.and(autocompleteOrNumberChoicesMixinOptionPredicate);
export const numberOptionPredicate = basicOptionPredicate
.merge(numericMixinNumberOptionPredicate)
.and(autocompleteOrNumberChoicesMixinOptionPredicate);
export const stringOptionPredicate = basicOptionPredicate
.extend({
max_length: z.number().min(0).max(6_000).optional(),
min_length: z.number().min(1).max(6_000).optional(),
})
.and(autocompleteOrStringChoicesMixinOptionPredicate);
const baseChatInputCommandPredicate = sharedNameAndDescriptionPredicate.extend({
contexts: z.array(z.nativeEnum(InteractionContextType)).optional(),
default_member_permissions: memberPermissionsPredicate.optional(),
integration_types: z.array(z.nativeEnum(ApplicationIntegrationType)).optional(),
nsfw: z.boolean().optional(),
});
// Because you can only add options via builders, there's no need to validate whole objects here otherwise
const chatInputCommandOptionsPredicate = z.union([
z.object({ type: basicOptionTypesPredicate }).array(),
z.object({ type: z.literal(ApplicationCommandOptionType.Subcommand) }).array(),
z.object({ type: z.literal(ApplicationCommandOptionType.SubcommandGroup) }).array(),
]);
export const chatInputCommandPredicate = baseChatInputCommandPredicate.extend({
options: chatInputCommandOptionsPredicate.optional(),
});
export const chatInputCommandSubcommandGroupPredicate = sharedNameAndDescriptionPredicate.extend({
type: z.literal(ApplicationCommandOptionType.SubcommandGroup),
options: z
.array(z.object({ type: z.literal(ApplicationCommandOptionType.Subcommand) }))
.min(1)
.max(25),
});
export const chatInputCommandSubcommandPredicate = sharedNameAndDescriptionPredicate.extend({
type: z.literal(ApplicationCommandOptionType.Subcommand),
options: z.array(z.object({ type: basicOptionTypesPredicate })).max(25),
});

View File

@@ -1,40 +0,0 @@
import { ApplicationCommandType, type RESTPostAPIChatInputApplicationCommandsJSONBody } from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { validate } from '../../../util/validation.js';
import { CommandBuilder } from '../Command.js';
import { SharedNameAndDescription } from '../SharedNameAndDescription.js';
import { chatInputCommandPredicate } from './Assertions.js';
import { SharedChatInputCommandOptions } from './mixins/SharedChatInputCommandOptions.js';
import { SharedChatInputCommandSubcommands } from './mixins/SharedSubcommands.js';
/**
* A builder that creates API-compatible JSON data for chat input commands.
*
* @mixes CommandBuilder<RESTPostAPIChatInputApplicationCommandsJSONBody>
* @mixes SharedChatInputCommandOptions
* @mixes SharedNameAndDescription
* @mixes SharedChatInputCommandSubcommands
*/
export class ChatInputCommandBuilder extends Mixin(
CommandBuilder<RESTPostAPIChatInputApplicationCommandsJSONBody>,
SharedChatInputCommandOptions,
SharedNameAndDescription,
SharedChatInputCommandSubcommands,
) {
/**
* {@inheritDoc CommandBuilder.toJSON}
*/
public toJSON(validationOverride?: boolean): RESTPostAPIChatInputApplicationCommandsJSONBody {
const { options, ...rest } = this.data;
const data: RESTPostAPIChatInputApplicationCommandsJSONBody = {
...structuredClone(rest as Omit<RESTPostAPIChatInputApplicationCommandsJSONBody, 'options'>),
type: ApplicationCommandType.ChatInput,
options: options?.map((option) => option.toJSON(validationOverride)),
};
validate(chatInputCommandPredicate, data, validationOverride);
return data;
}
}

View File

@@ -1,107 +0,0 @@
import type { JSONEncodable } from '@discordjs/util';
import type {
APIApplicationCommandSubcommandOption,
APIApplicationCommandSubcommandGroupOption,
} from 'discord-api-types/v10';
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray.js';
import { resolveBuilder } from '../../../util/resolveBuilder.js';
import { validate } from '../../../util/validation.js';
import type { SharedNameAndDescriptionData } from '../SharedNameAndDescription.js';
import { SharedNameAndDescription } from '../SharedNameAndDescription.js';
import { chatInputCommandSubcommandGroupPredicate, chatInputCommandSubcommandPredicate } from './Assertions.js';
import { SharedChatInputCommandOptions } from './mixins/SharedChatInputCommandOptions.js';
export interface ChatInputCommandSubcommandGroupData {
options?: ChatInputCommandSubcommandBuilder[];
}
/**
* Represents a folder for subcommands.
*
* @see {@link https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups}
*/
export class ChatInputCommandSubcommandGroupBuilder
extends SharedNameAndDescription
implements JSONEncodable<APIApplicationCommandSubcommandGroupOption>
{
protected declare readonly data: ChatInputCommandSubcommandGroupData & SharedNameAndDescriptionData;
public get options(): readonly ChatInputCommandSubcommandBuilder[] {
return (this.data.options ??= []);
}
/**
* Adds a new subcommand to this group.
*
* @param input - A function that returns a subcommand builder or an already built builder
*/
public addSubcommands(
...input: RestOrArray<
| ChatInputCommandSubcommandBuilder
| ((subcommandGroup: ChatInputCommandSubcommandBuilder) => ChatInputCommandSubcommandBuilder)
>
) {
const normalized = normalizeArray(input);
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const result = normalized.map((builder) => resolveBuilder(builder, ChatInputCommandSubcommandBuilder));
this.data.options ??= [];
this.data.options.push(...result);
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): APIApplicationCommandSubcommandGroupOption {
const { options, ...rest } = this.data;
const data = {
...(structuredClone(rest) as Omit<APIApplicationCommandSubcommandGroupOption, 'type'>),
type: ApplicationCommandOptionType.SubcommandGroup as const,
options: options?.map((option) => option.toJSON(validationOverride)) ?? [],
};
validate(chatInputCommandSubcommandGroupPredicate, data, validationOverride);
return data;
}
}
/**
* A builder that creates API-compatible JSON data for chat input command subcommands.
*
* @see {@link https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups}
*/
export class ChatInputCommandSubcommandBuilder
extends Mixin(SharedNameAndDescription, SharedChatInputCommandOptions)
implements JSONEncodable<APIApplicationCommandSubcommandOption>
{
/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): APIApplicationCommandSubcommandOption {
const { options, ...rest } = this.data;
const data = {
...(structuredClone(rest) as Omit<APIApplicationCommandSubcommandOption, 'type'>),
type: ApplicationCommandOptionType.Subcommand as const,
options: options?.map((option) => option.toJSON(validationOverride)) ?? [],
};
validate(chatInputCommandSubcommandPredicate, data, validationOverride);
return data;
}
}

View File

@@ -1,47 +0,0 @@
import type { APIApplicationCommandIntegerOption } from 'discord-api-types/v10';
export interface ApplicationCommandNumericOptionMinMaxValueData
extends Pick<APIApplicationCommandIntegerOption, 'max_value' | 'min_value'> {}
/**
* This mixin holds minimum and maximum symbols used for options.
*/
export abstract class ApplicationCommandNumericOptionMinMaxValueMixin {
protected declare readonly data: ApplicationCommandNumericOptionMinMaxValueData;
/**
* Sets the maximum number value of this option.
*
* @param max - The maximum value this option can be
*/
public setMaxValue(max: number): this {
this.data.max_value = max;
return this;
}
/**
* Removes the maximum number value of this option.
*/
public clearMaxValue(): this {
this.data.max_value = undefined;
return this;
}
/**
* Sets the minimum number value of this option.
*
* @param min - The minimum value this option can be
*/
public setMinValue(min: number): this {
this.data.min_value = min;
return this;
}
/**
* Removes the minimum number value of this option.
*/
public clearMinValue(): this {
this.data.min_value = undefined;
return this;
}
}

View File

@@ -1,52 +0,0 @@
import { ChannelType, type APIApplicationCommandChannelOption } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../../../../util/normalizeArray';
export const ApplicationCommandOptionAllowedChannelTypes = [
ChannelType.GuildText,
ChannelType.GuildVoice,
ChannelType.GuildCategory,
ChannelType.GuildAnnouncement,
ChannelType.AnnouncementThread,
ChannelType.PublicThread,
ChannelType.PrivateThread,
ChannelType.GuildStageVoice,
ChannelType.GuildForum,
ChannelType.GuildMedia,
] as const;
/**
* Allowed channel types used for a channel option.
*/
export type ApplicationCommandOptionAllowedChannelTypes = (typeof ApplicationCommandOptionAllowedChannelTypes)[number];
export interface ApplicationCommandOptionChannelTypesData
extends Pick<APIApplicationCommandChannelOption, 'channel_types'> {}
/**
* This mixin holds channel type symbols used for options.
*/
export class ApplicationCommandOptionChannelTypesMixin {
protected declare readonly data: ApplicationCommandOptionChannelTypesData;
/**
* Adds channel types to this option.
*
* @param channelTypes - The channel types
*/
public addChannelTypes(...channelTypes: RestOrArray<ApplicationCommandOptionAllowedChannelTypes>) {
this.data.channel_types ??= [];
this.data.channel_types.push(...normalizeArray(channelTypes));
return this;
}
/**
* Sets the channel types for this option.
*
* @param channelTypes - The channel types
*/
public setChannelTypes(...channelTypes: RestOrArray<ApplicationCommandOptionAllowedChannelTypes>) {
this.data.channel_types = normalizeArray(channelTypes);
return this;
}
}

View File

@@ -1,29 +0,0 @@
import type {
APIApplicationCommandIntegerOption,
APIApplicationCommandNumberOption,
APIApplicationCommandStringOption,
} from 'discord-api-types/v10';
export type AutocompletableOptions =
| APIApplicationCommandIntegerOption
| APIApplicationCommandNumberOption
| APIApplicationCommandStringOption;
export interface ApplicationCommandOptionWithAutocompleteData extends Pick<AutocompletableOptions, 'autocomplete'> {}
/**
* This mixin holds choices and autocomplete symbols used for options.
*/
export class ApplicationCommandOptionWithAutocompleteMixin {
protected declare readonly data: ApplicationCommandOptionWithAutocompleteData;
/**
* Whether this option uses autocomplete.
*
* @param autocomplete - Whether this option should use autocomplete
*/
public setAutocomplete(autocomplete = true): this {
this.data.autocomplete = autocomplete;
return this;
}
}

View File

@@ -1,38 +0,0 @@
import type { APIApplicationCommandOptionChoice } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../../../../util/normalizeArray.js';
// Unlike other places, we're not `Pick`ing from discord-api-types. The union includes `[]` and it breaks everything.
export interface ApplicationCommandOptionWithChoicesData {
choices?: APIApplicationCommandOptionChoice<number | string>[];
}
/**
* This mixin holds choices and autocomplete symbols used for options.
*/
export class ApplicationCommandOptionWithChoicesMixin<ChoiceType extends number | string> {
protected declare readonly data: ApplicationCommandOptionWithChoicesData;
/**
* Adds multiple choices to this option.
*
* @param choices - The choices to add
*/
public addChoices(...choices: RestOrArray<APIApplicationCommandOptionChoice<ChoiceType>>): this {
const normalizedChoices = normalizeArray(choices);
this.data.choices ??= [];
this.data.choices.push(...normalizedChoices);
return this;
}
/**
* Sets multiple choices for this option.
*
* @param choices - The choices to set
*/
public setChoices(...choices: RestOrArray<APIApplicationCommandOptionChoice<ChoiceType>>): this {
this.data.choices = normalizeArray(choices);
return this;
}
}

View File

@@ -1,200 +0,0 @@
import { normalizeArray, type RestOrArray } from '../../../../util/normalizeArray.js';
import { resolveBuilder } from '../../../../util/resolveBuilder.js';
import type { ApplicationCommandOptionBase } from '../options/ApplicationCommandOptionBase.js';
import { ChatInputCommandAttachmentOption } from '../options/attachment.js';
import { ChatInputCommandBooleanOption } from '../options/boolean.js';
import { ChatInputCommandChannelOption } from '../options/channel.js';
import { ChatInputCommandIntegerOption } from '../options/integer.js';
import { ChatInputCommandMentionableOption } from '../options/mentionable.js';
import { ChatInputCommandNumberOption } from '../options/number.js';
import { ChatInputCommandRoleOption } from '../options/role.js';
import { ChatInputCommandStringOption } from '../options/string.js';
import { ChatInputCommandUserOption } from '../options/user.js';
export interface SharedChatInputCommandOptionsData {
options?: ApplicationCommandOptionBase[];
}
/**
* This mixin holds symbols that can be shared in chat input command options.
*
* @typeParam TypeAfterAddingOptions - The type this class should return after adding an option.
*/
export class SharedChatInputCommandOptions {
protected declare readonly data: SharedChatInputCommandOptionsData;
public get options(): readonly ApplicationCommandOptionBase[] {
return (this.data.options ??= []);
}
/**
* Adds boolean options.
*
* @param options - Options to add
*/
public addBooleanOptions(
...options: RestOrArray<
ChatInputCommandBooleanOption | ((builder: ChatInputCommandBooleanOption) => ChatInputCommandBooleanOption)
>
) {
return this.sharedAddOptions(ChatInputCommandBooleanOption, ...options);
}
/**
* Adds user options.
*
* @param options - Options to add
*/
public addUserOptions(
...options: RestOrArray<
ChatInputCommandUserOption | ((builder: ChatInputCommandUserOption) => ChatInputCommandUserOption)
>
) {
return this.sharedAddOptions(ChatInputCommandUserOption, ...options);
}
/**
* Adds channel options.
*
* @param options - Options to add
*/
public addChannelOptions(
...options: RestOrArray<
ChatInputCommandChannelOption | ((builder: ChatInputCommandChannelOption) => ChatInputCommandChannelOption)
>
) {
return this.sharedAddOptions(ChatInputCommandChannelOption, ...options);
}
/**
* Adds role options.
*
* @param options - Options to add
*/
public addRoleOptions(
...options: RestOrArray<
ChatInputCommandRoleOption | ((builder: ChatInputCommandRoleOption) => ChatInputCommandRoleOption)
>
) {
return this.sharedAddOptions(ChatInputCommandRoleOption, ...options);
}
/**
* Adds attachment options.
*
* @param options - Options to add
*/
public addAttachmentOptions(
...options: RestOrArray<
| ChatInputCommandAttachmentOption
| ((builder: ChatInputCommandAttachmentOption) => ChatInputCommandAttachmentOption)
>
) {
return this.sharedAddOptions(ChatInputCommandAttachmentOption, ...options);
}
/**
* Adds mentionable options.
*
* @param options - Options to add
*/
public addMentionableOptions(
...options: RestOrArray<
| ChatInputCommandMentionableOption
| ((builder: ChatInputCommandMentionableOption) => ChatInputCommandMentionableOption)
>
) {
return this.sharedAddOptions(ChatInputCommandMentionableOption, ...options);
}
/**
* Adds string options.
*
* @param options - Options to add
*/
public addStringOptions(
...options: RestOrArray<
ChatInputCommandStringOption | ((builder: ChatInputCommandStringOption) => ChatInputCommandStringOption)
>
) {
return this.sharedAddOptions(ChatInputCommandStringOption, ...options);
}
/**
* Adds integer options.
*
* @param options - Options to add
*/
public addIntegerOptions(
...options: RestOrArray<
ChatInputCommandIntegerOption | ((builder: ChatInputCommandIntegerOption) => ChatInputCommandIntegerOption)
>
) {
return this.sharedAddOptions(ChatInputCommandIntegerOption, ...options);
}
/**
* Adds number options.
*
* @param options - Options to add
*/
public addNumberOptions(
...options: RestOrArray<
ChatInputCommandNumberOption | ((builder: ChatInputCommandNumberOption) => ChatInputCommandNumberOption)
>
) {
return this.sharedAddOptions(ChatInputCommandNumberOption, ...options);
}
/**
* Removes, replaces, or inserts options for this command.
*
* @remarks
* This method behaves similarly
* to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}.
*
* It's useful for modifying and adjusting order of the already-existing options for this command.
* @example
* Remove the first option:
* ```ts
* actionRow.spliceOptions(0, 1);
* ```
* @example
* Remove the first n options:
* ```ts
* const n = 4;
* actionRow.spliceOptions(0, n);
* ```
* @example
* Remove the last option:
* ```ts
* actionRow.spliceOptions(-1, 1);
* ```
* @param index - The index to start at
* @param deleteCount - The number of options to remove
* @param options - The replacing option objects
*/
public spliceOptions(index: number, deleteCount: number, ...options: ApplicationCommandOptionBase[]): this {
this.data.options ??= [];
this.data.options.splice(index, deleteCount, ...options);
return this;
}
/**
* Where the actual adding magic happens. ✨
*
* @internal
*/
private sharedAddOptions<OptionBuilder extends ApplicationCommandOptionBase>(
Instance: new () => OptionBuilder,
...options: RestOrArray<OptionBuilder | ((builder: OptionBuilder) => OptionBuilder)>
): this {
const normalized = normalizeArray(options);
const resolved = normalized.map((option) => resolveBuilder(option, Instance));
this.data.options ??= [];
this.data.options.push(...resolved);
return this;
}
}

View File

@@ -1,60 +0,0 @@
import type { RestOrArray } from '../../../../util/normalizeArray.js';
import { normalizeArray } from '../../../../util/normalizeArray.js';
import { resolveBuilder } from '../../../../util/resolveBuilder.js';
import {
ChatInputCommandSubcommandGroupBuilder,
ChatInputCommandSubcommandBuilder,
} from '../ChatInputCommandSubcommands.js';
export interface SharedChatInputCommandSubcommandsData {
options?: (ChatInputCommandSubcommandBuilder | ChatInputCommandSubcommandGroupBuilder)[];
}
/**
* This mixin holds symbols that can be shared in chat input subcommands.
*
* @typeParam TypeAfterAddingSubcommands - The type this class should return after adding a subcommand or subcommand group.
*/
export class SharedChatInputCommandSubcommands {
protected declare readonly data: SharedChatInputCommandSubcommandsData;
/**
* Adds subcommand groups to this command.
*
* @param input - Subcommand groups to add
*/
public addSubcommandGroups(
...input: RestOrArray<
| ChatInputCommandSubcommandGroupBuilder
| ((subcommandGroup: ChatInputCommandSubcommandGroupBuilder) => ChatInputCommandSubcommandGroupBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((value) => resolveBuilder(value, ChatInputCommandSubcommandGroupBuilder));
this.data.options ??= [];
this.data.options.push(...resolved);
return this;
}
/**
* Adds subcommands to this command.
*
* @param input - Subcommands to add
*/
public addSubcommands(
...input: RestOrArray<
| ChatInputCommandSubcommandBuilder
| ((subcommandGroup: ChatInputCommandSubcommandBuilder) => ChatInputCommandSubcommandBuilder)
>
): this {
const normalized = normalizeArray(input);
const resolved = normalized.map((value) => resolveBuilder(value, ChatInputCommandSubcommandBuilder));
this.data.options ??= [];
this.data.options.push(...resolved);
return this;
}
}

View File

@@ -1,56 +0,0 @@
import type { JSONEncodable } from '@discordjs/util';
import type {
APIApplicationCommandBasicOption,
APIApplicationCommandOption,
ApplicationCommandOptionType,
} from 'discord-api-types/v10';
import type { z } from 'zod';
import { validate } from '../../../../util/validation.js';
import type { SharedNameAndDescriptionData } from '../../SharedNameAndDescription.js';
import { SharedNameAndDescription } from '../../SharedNameAndDescription.js';
import { basicOptionPredicate } from '../Assertions.js';
export interface ApplicationCommandOptionBaseData extends Partial<Pick<APIApplicationCommandOption, 'required'>> {
type: ApplicationCommandOptionType;
}
/**
* The base application command option builder that contains common symbols for application command builders.
*/
export abstract class ApplicationCommandOptionBase
extends SharedNameAndDescription
implements JSONEncodable<APIApplicationCommandBasicOption>
{
protected static readonly predicate: z.ZodTypeAny = basicOptionPredicate;
protected declare readonly data: ApplicationCommandOptionBaseData & SharedNameAndDescriptionData;
public constructor(type: ApplicationCommandOptionType) {
super();
this.data.type = type;
}
/**
* Sets whether this option is required.
*
* @param required - Whether this option should be required
*/
public setRequired(required = true) {
this.data.required = required;
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): APIApplicationCommandBasicOption {
const clone = structuredClone(this.data);
validate((this.constructor as typeof ApplicationCommandOptionBase).predicate, clone, validationOverride);
return clone as APIApplicationCommandBasicOption;
}
}

View File

@@ -1,11 +0,0 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command attachment option.
*/
export class ChatInputCommandAttachmentOption extends ApplicationCommandOptionBase {
public constructor() {
super(ApplicationCommandOptionType.Attachment);
}
}

View File

@@ -1,11 +0,0 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command boolean option.
*/
export class ChatInputCommandBooleanOption extends ApplicationCommandOptionBase {
public constructor() {
super(ApplicationCommandOptionType.Boolean);
}
}

View File

@@ -1,19 +0,0 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { channelOptionPredicate } from '../Assertions.js';
import { ApplicationCommandOptionChannelTypesMixin } from '../mixins/ApplicationCommandOptionChannelTypesMixin.js';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command channel option.
*/
export class ChatInputCommandChannelOption extends Mixin(
ApplicationCommandOptionBase,
ApplicationCommandOptionChannelTypesMixin,
) {
protected static override readonly predicate = channelOptionPredicate;
public constructor() {
super(ApplicationCommandOptionType.Channel);
}
}

View File

@@ -1,23 +0,0 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { integerOptionPredicate } from '../Assertions.js';
import { ApplicationCommandNumericOptionMinMaxValueMixin } from '../mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js';
import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js';
import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command integer option.
*/
export class ChatInputCommandIntegerOption extends Mixin(
ApplicationCommandOptionBase,
ApplicationCommandNumericOptionMinMaxValueMixin,
ApplicationCommandOptionWithAutocompleteMixin,
ApplicationCommandOptionWithChoicesMixin<number>,
) {
protected static override readonly predicate = integerOptionPredicate;
public constructor() {
super(ApplicationCommandOptionType.Integer);
}
}

View File

@@ -1,11 +0,0 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command mentionable option.
*/
export class ChatInputCommandMentionableOption extends ApplicationCommandOptionBase {
public constructor() {
super(ApplicationCommandOptionType.Mentionable);
}
}

View File

@@ -1,23 +0,0 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { numberOptionPredicate } from '../Assertions.js';
import { ApplicationCommandNumericOptionMinMaxValueMixin } from '../mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js';
import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js';
import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command number option.
*/
export class ChatInputCommandNumberOption extends Mixin(
ApplicationCommandOptionBase,
ApplicationCommandNumericOptionMinMaxValueMixin,
ApplicationCommandOptionWithAutocompleteMixin,
ApplicationCommandOptionWithChoicesMixin<number>,
) {
protected static override readonly predicate = numberOptionPredicate;
public constructor() {
super(ApplicationCommandOptionType.Number);
}
}

View File

@@ -1,11 +0,0 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command role option.
*/
export class ChatInputCommandRoleOption extends ApplicationCommandOptionBase {
public constructor() {
super(ApplicationCommandOptionType.Role);
}
}

View File

@@ -1,65 +0,0 @@
import { ApplicationCommandOptionType, type APIApplicationCommandStringOption } from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { stringOptionPredicate } from '../Assertions.js';
import type { ApplicationCommandOptionWithAutocompleteData } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js';
import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js';
import type { ApplicationCommandOptionWithChoicesData } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js';
import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
import type { ApplicationCommandOptionBaseData } from './ApplicationCommandOptionBase.js';
/**
* A chat input command string option.
*/
export class ChatInputCommandStringOption extends Mixin(
ApplicationCommandOptionBase,
ApplicationCommandOptionWithAutocompleteMixin,
ApplicationCommandOptionWithChoicesMixin<string>,
) {
protected static override readonly predicate = stringOptionPredicate;
protected declare readonly data: ApplicationCommandOptionBaseData &
ApplicationCommandOptionWithAutocompleteData &
ApplicationCommandOptionWithChoicesData &
Partial<Pick<APIApplicationCommandStringOption, 'max_length' | 'min_length'>>;
public constructor() {
super(ApplicationCommandOptionType.String);
}
/**
* Sets the maximum length of this string option.
*
* @param max - The maximum length this option can be
*/
public setMaxLength(max: number): this {
this.data.max_length = max;
return this;
}
/**
* Clears the maximum length of this string option.
*/
public clearMaxLength(): this {
this.data.max_length = undefined;
return this;
}
/**
* Sets the minimum length of this string option.
*
* @param min - The minimum length this option can be
*/
public setMinLength(min: number): this {
this.data.min_length = min;
return this;
}
/**
* Clears the minimum length of this string option.
*/
public clearMinLength(): this {
this.data.min_length = undefined;
return this;
}
}

View File

@@ -1,11 +0,0 @@
import { ApplicationCommandOptionType } from 'discord-api-types/v10';
import { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* A chat input command user option.
*/
export class ChatInputCommandUserOption extends ApplicationCommandOptionBase {
public constructor() {
super(ApplicationCommandOptionType.User);
}
}

View File

@@ -1,30 +0,0 @@
import { ApplicationCommandType, ApplicationIntegrationType, InteractionContextType } from 'discord-api-types/v10';
import { z } from 'zod';
import { localeMapPredicate, memberPermissionsPredicate } from '../../../Assertions.js';
const namePredicate = z
.string()
.min(1)
.max(32)
// eslint-disable-next-line prefer-named-capture-group
.regex(/^( *[\p{P}\p{L}\p{N}\p{sc=Devanagari}\p{sc=Thai}]+ *)+$/u);
const contextsPredicate = z.array(z.nativeEnum(InteractionContextType));
const integrationTypesPredicate = z.array(z.nativeEnum(ApplicationIntegrationType));
const baseContextMenuCommandPredicate = z.object({
contexts: contextsPredicate.optional(),
default_member_permissions: memberPermissionsPredicate.optional(),
name: namePredicate,
name_localizations: localeMapPredicate.optional(),
integration_types: integrationTypesPredicate.optional(),
nsfw: z.boolean().optional(),
});
export const userCommandPredicate = baseContextMenuCommandPredicate.extend({
type: z.literal(ApplicationCommandType.User),
});
export const messageCommandPredicate = baseContextMenuCommandPredicate.extend({
type: z.literal(ApplicationCommandType.Message),
});

View File

@@ -1,29 +0,0 @@
import type { ApplicationCommandType, RESTPostAPIContextMenuApplicationCommandsJSONBody } from 'discord-api-types/v10';
import { Mixin } from 'ts-mixer';
import { CommandBuilder } from '../Command.js';
import { SharedName } from '../SharedName.js';
/**
* The type a context menu command can be.
*/
export type ContextMenuCommandType = ApplicationCommandType.Message | ApplicationCommandType.User;
/**
* A builder that creates API-compatible JSON data for context menu commands.
*/
export abstract class ContextMenuCommandBuilder extends Mixin(
CommandBuilder<RESTPostAPIContextMenuApplicationCommandsJSONBody>,
SharedName,
) {
protected override readonly data: Partial<RESTPostAPIContextMenuApplicationCommandsJSONBody>;
public constructor(data: Partial<RESTPostAPIContextMenuApplicationCommandsJSONBody> = {}) {
super();
this.data = structuredClone(data);
}
/**
* {@inheritDoc CommandBuilder.toJSON}
*/
public abstract override toJSON(validationOverride?: boolean): RESTPostAPIContextMenuApplicationCommandsJSONBody;
}

View File

@@ -1,16 +0,0 @@
import { ApplicationCommandType, type RESTPostAPIContextMenuApplicationCommandsJSONBody } from 'discord-api-types/v10';
import { validate } from '../../../util/validation.js';
import { messageCommandPredicate } from './Assertions.js';
import { ContextMenuCommandBuilder } from './ContextMenuCommand.js';
export class MessageContextCommandBuilder extends ContextMenuCommandBuilder {
/**
* {@inheritDoc CommandBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): RESTPostAPIContextMenuApplicationCommandsJSONBody {
const data = { ...structuredClone(this.data), type: ApplicationCommandType.Message };
validate(messageCommandPredicate, data, validationOverride);
return data as RESTPostAPIContextMenuApplicationCommandsJSONBody;
}
}

View File

@@ -1,16 +0,0 @@
import { ApplicationCommandType, type RESTPostAPIContextMenuApplicationCommandsJSONBody } from 'discord-api-types/v10';
import { validate } from '../../../util/validation.js';
import { userCommandPredicate } from './Assertions.js';
import { ContextMenuCommandBuilder } from './ContextMenuCommand.js';
export class UserContextCommandBuilder extends ContextMenuCommandBuilder {
/**
* {@inheritDoc CommandBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): RESTPostAPIContextMenuApplicationCommandsJSONBody {
const data = { ...structuredClone(this.data), type: ApplicationCommandType.User };
validate(userCommandPredicate, data, validationOverride);
return data as RESTPostAPIContextMenuApplicationCommandsJSONBody;
}
}

View File

@@ -0,0 +1,65 @@
import { s } from '@sapphire/shapeshift';
import { ApplicationCommandType, ApplicationIntegrationType, InteractionContextType } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import type { ContextMenuCommandType } from './ContextMenuCommandBuilder.js';
const namePredicate = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(32)
// eslint-disable-next-line prefer-named-capture-group
.regex(/^( *[\p{P}\p{L}\p{N}\p{sc=Devanagari}\p{sc=Thai}]+ *)+$/u)
.setValidationEnabled(isValidationEnabled);
const typePredicate = s
.union([s.literal(ApplicationCommandType.User), s.literal(ApplicationCommandType.Message)])
.setValidationEnabled(isValidationEnabled);
const booleanPredicate = s.boolean();
export function validateDefaultPermission(value: unknown): asserts value is boolean {
booleanPredicate.parse(value);
}
export function validateName(name: unknown): asserts name is string {
namePredicate.parse(name);
}
export function validateType(type: unknown): asserts type is ContextMenuCommandType {
typePredicate.parse(type);
}
export function validateRequiredParameters(name: string, type: number) {
// Assert name matches all conditions
validateName(name);
// Assert type is valid
validateType(type);
}
const dmPermissionPredicate = s.boolean().nullish();
export function validateDMPermission(value: unknown): asserts value is boolean | null | undefined {
dmPermissionPredicate.parse(value);
}
const memberPermissionPredicate = s
.union([
s.bigint().transform((value) => value.toString()),
s
.number()
.safeInt()
.transform((value) => value.toString()),
s.string().regex(/^\d+$/),
])
.nullish();
export function validateDefaultMemberPermissions(permissions: unknown) {
return memberPermissionPredicate.parse(permissions);
}
export const contextsPredicate = s.array(
s.nativeEnum(InteractionContextType).setValidationEnabled(isValidationEnabled),
);
export const integrationTypesPredicate = s.array(
s.nativeEnum(ApplicationIntegrationType).setValidationEnabled(isValidationEnabled),
);

View File

@@ -0,0 +1,239 @@
import type {
ApplicationCommandType,
ApplicationIntegrationType,
InteractionContextType,
LocaleString,
LocalizationMap,
Permissions,
RESTPostAPIContextMenuApplicationCommandsJSONBody,
} from 'discord-api-types/v10';
import type { RestOrArray } from '../../util/normalizeArray.js';
import { normalizeArray } from '../../util/normalizeArray.js';
import { validateLocale, validateLocalizationMap } from '../slashCommands/Assertions.js';
import {
validateRequiredParameters,
validateName,
validateType,
validateDefaultPermission,
validateDefaultMemberPermissions,
validateDMPermission,
contextsPredicate,
integrationTypesPredicate,
} from './Assertions.js';
/**
* The type a context menu command can be.
*/
export type ContextMenuCommandType = ApplicationCommandType.Message | ApplicationCommandType.User;
/**
* A builder that creates API-compatible JSON data for context menu commands.
*/
export class ContextMenuCommandBuilder {
/**
* The name of this command.
*/
public readonly name: string = undefined!;
/**
* The name localizations of this command.
*/
public readonly name_localizations?: LocalizationMap;
/**
* The type of this command.
*/
public readonly type: ContextMenuCommandType = undefined!;
/**
* The contexts for this command.
*/
public readonly contexts?: InteractionContextType[];
/**
* Whether this command is enabled by default when the application is added to a guild.
*
* @deprecated Use {@link ContextMenuCommandBuilder.setDefaultMemberPermissions} or {@link ContextMenuCommandBuilder.setDMPermission} instead.
*/
public readonly default_permission: boolean | undefined = undefined;
/**
* The set of permissions represented as a bit set for the command.
*/
public readonly default_member_permissions: Permissions | null | undefined = undefined;
/**
* Indicates whether the command is available in direct messages with the application.
*
* @remarks
* By default, commands are visible. This property is only for global commands.
* @deprecated
* Use {@link ContextMenuCommandBuilder.contexts} instead.
*/
public readonly dm_permission: boolean | undefined = undefined;
/**
* The integration types for this command.
*/
public readonly integration_types?: ApplicationIntegrationType[];
/**
* Sets the contexts of this command.
*
* @param contexts - The contexts
*/
public setContexts(...contexts: RestOrArray<InteractionContextType>) {
Reflect.set(this, 'contexts', contextsPredicate.parse(normalizeArray(contexts)));
return this;
}
/**
* Sets integration types of this command.
*
* @param integrationTypes - The integration types
*/
public setIntegrationTypes(...integrationTypes: RestOrArray<ApplicationIntegrationType>) {
Reflect.set(this, 'integration_types', integrationTypesPredicate.parse(normalizeArray(integrationTypes)));
return this;
}
/**
* Sets the name of this command.
*
* @param name - The name to use
*/
public setName(name: string) {
// Assert the name matches the conditions
validateName(name);
Reflect.set(this, 'name', name);
return this;
}
/**
* Sets the type of this command.
*
* @param type - The type to use
*/
public setType(type: ContextMenuCommandType) {
// Assert the type is valid
validateType(type);
Reflect.set(this, 'type', type);
return this;
}
/**
* Sets whether the command is enabled by default when the application is added to a guild.
*
* @remarks
* If set to `false`, you will have to later `PUT` the permissions for this command.
* @param value - Whether to enable this command by default
* @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions}
* @deprecated Use {@link ContextMenuCommandBuilder.setDefaultMemberPermissions} or {@link ContextMenuCommandBuilder.setDMPermission} instead.
*/
public setDefaultPermission(value: boolean) {
// Assert the value matches the conditions
validateDefaultPermission(value);
Reflect.set(this, 'default_permission', value);
return this;
}
/**
* Sets the default permissions a member should have in order to run this command.
*
* @remarks
* You can set this to `'0'` to disable the command by default.
* @param permissions - The permissions bit field to set
* @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions}
*/
public setDefaultMemberPermissions(permissions: Permissions | bigint | number | null | undefined) {
// Assert the value and parse it
const permissionValue = validateDefaultMemberPermissions(permissions);
Reflect.set(this, 'default_member_permissions', permissionValue);
return this;
}
/**
* Sets if the command is available in direct messages with the application.
*
* @remarks
* By default, commands are visible. This method is only for global commands.
* @param enabled - Whether the command should be enabled in direct messages
* @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions}
* @deprecated Use {@link ContextMenuCommandBuilder.setContexts} instead.
*/
public setDMPermission(enabled: boolean | null | undefined) {
// Assert the value matches the conditions
validateDMPermission(enabled);
Reflect.set(this, 'dm_permission', enabled);
return this;
}
/**
* Sets a name localization for this command.
*
* @param locale - The locale to set
* @param localizedName - The localized name for the given `locale`
*/
public setNameLocalization(locale: LocaleString, localizedName: string | null) {
if (!this.name_localizations) {
Reflect.set(this, 'name_localizations', {});
}
const parsedLocale = validateLocale(locale);
if (localizedName === null) {
this.name_localizations![parsedLocale] = null;
return this;
}
validateName(localizedName);
this.name_localizations![parsedLocale] = localizedName;
return this;
}
/**
* Sets the name localizations for this command.
*
* @param localizedNames - The object of localized names to set
*/
public setNameLocalizations(localizedNames: LocalizationMap | null) {
if (localizedNames === null) {
Reflect.set(this, 'name_localizations', null);
return this;
}
Reflect.set(this, 'name_localizations', {});
for (const args of Object.entries(localizedNames))
this.setNameLocalization(...(args as [LocaleString, string | null]));
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* @remarks
* This method runs validations on the data before serializing it.
* As such, it may throw an error if the data is invalid.
*/
public toJSON(): RESTPostAPIContextMenuApplicationCommandsJSONBody {
validateRequiredParameters(this.name, this.type);
validateLocalizationMap(this.name_localizations);
return { ...this };
}
}

View File

@@ -1,21 +1,25 @@
import { ComponentType } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate } from '../../Assertions.js';
import { s } from '@sapphire/shapeshift';
import { ActionRowBuilder, type ModalActionRowComponentBuilder } from '../../components/ActionRow.js';
import { customIdValidator } from '../../components/Assertions.js';
import { isValidationEnabled } from '../../util/validation.js';
const titlePredicate = z.string().min(1).max(45);
export const titleValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(45)
.setValidationEnabled(isValidationEnabled);
export const componentsValidator = s
.instance(ActionRowBuilder)
.array()
.lengthGreaterThanOrEqual(1)
.setValidationEnabled(isValidationEnabled);
export const modalPredicate = z.object({
title: titlePredicate,
custom_id: customIdPredicate,
components: z
.object({
type: z.literal(ComponentType.ActionRow),
components: z
.object({ type: z.literal(ComponentType.TextInput) })
.array()
.length(1),
})
.array()
.min(1)
.max(5),
});
export function validateRequiredParameters(
customId?: string,
title?: string,
components?: ActionRowBuilder<ModalActionRowComponentBuilder>[],
) {
customIdValidator.parse(customId);
titleValidator.parse(title);
componentsValidator.parse(components);
}

View File

@@ -6,16 +6,11 @@ import type {
APIModalActionRowComponent,
APIModalInteractionResponseCallbackData,
} from 'discord-api-types/v10';
import { ActionRowBuilder } from '../../components/ActionRow.js';
import { ActionRowBuilder, type ModalActionRowComponentBuilder } from '../../components/ActionRow.js';
import { customIdValidator } from '../../components/Assertions.js';
import { createComponentBuilder } from '../../components/Components.js';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import { resolveBuilder } from '../../util/resolveBuilder.js';
import { validate } from '../../util/validation.js';
import { modalPredicate } from './Assertions.js';
export interface ModalBuilderData extends Partial<Omit<APIModalInteractionResponseCallbackData, 'components'>> {
components: ActionRowBuilder[];
}
import { titleValidator, validateRequiredParameters } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for modals.
@@ -24,25 +19,22 @@ export class ModalBuilder implements JSONEncodable<APIModalInteractionResponseCa
/**
* The API data associated with this modal.
*/
private readonly data: ModalBuilderData;
public readonly data: Partial<APIModalInteractionResponseCallbackData>;
/**
* The components within this modal.
*/
public get components(): readonly ActionRowBuilder[] {
return this.data.components;
}
public readonly components: ActionRowBuilder<ModalActionRowComponentBuilder>[] = [];
/**
* Creates a new modal from API data.
*
* @param data - The API data to create this modal with
*/
public constructor({ components = [], ...data }: Partial<APIModalInteractionResponseCallbackData> = {}) {
this.data = {
...structuredClone(data),
components: components.map((component) => createComponentBuilder(component)),
};
public constructor({ components, ...data }: Partial<APIModalInteractionResponseCallbackData> = {}) {
this.data = { ...data };
this.components = (components?.map((component) => createComponentBuilder(component)) ??
[]) as ActionRowBuilder<ModalActionRowComponentBuilder>[];
}
/**
@@ -51,7 +43,7 @@ export class ModalBuilder implements JSONEncodable<APIModalInteractionResponseCa
* @param title - The title to use
*/
public setTitle(title: string) {
this.data.title = title;
this.data.title = titleValidator.parse(title);
return this;
}
@@ -61,109 +53,49 @@ export class ModalBuilder implements JSONEncodable<APIModalInteractionResponseCa
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customId;
this.data.custom_id = customIdValidator.parse(customId);
return this;
}
/**
* Adds action rows to this modal.
* Adds components to this modal.
*
* @param components - The components to add
*/
public addActionRows(
public addComponents(
...components: RestOrArray<
| ActionRowBuilder
| APIActionRowComponent<APIModalActionRowComponent>
| ((builder: ActionRowBuilder) => ActionRowBuilder)
ActionRowBuilder<ModalActionRowComponentBuilder> | APIActionRowComponent<APIModalActionRowComponent>
>
) {
const normalized = normalizeArray(components);
const resolved = normalized.map((row) => resolveBuilder(row, ActionRowBuilder));
this.data.components.push(...resolved);
this.components.push(
...normalizeArray(components).map((component) =>
component instanceof ActionRowBuilder
? component
: new ActionRowBuilder<ModalActionRowComponentBuilder>(component),
),
);
return this;
}
/**
* Sets the action rows for this modal.
* Sets components for this modal.
*
* @param components - The components to set
*/
public setActionRows(
...components: RestOrArray<
| ActionRowBuilder
| APIActionRowComponent<APIModalActionRowComponent>
| ((builder: ActionRowBuilder) => ActionRowBuilder)
>
) {
const normalized = normalizeArray(components);
this.spliceActionRows(0, this.data.components.length, ...normalized);
public setComponents(...components: RestOrArray<ActionRowBuilder<ModalActionRowComponentBuilder>>) {
this.components.splice(0, this.components.length, ...normalizeArray(components));
return this;
}
/**
* Removes, replaces, or inserts action rows for this modal.
*
* @remarks
* This method behaves similarly
* to {@link https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}.
* The maximum amount of action rows that can be added is 5.
*
* It's useful for modifying and adjusting order of the already-existing action rows of a modal.
* @example
* Remove the first action row:
* ```ts
* embed.spliceActionRows(0, 1);
* ```
* @example
* Remove the first n action rows:
* ```ts
* const n = 4;
* embed.spliceActionRows(0, n);
* ```
* @example
* Remove the last action row:
* ```ts
* embed.spliceActionRows(-1, 1);
* ```
* @param index - The index to start at
* @param deleteCount - The number of action rows to remove
* @param rows - The replacing action row objects
* {@inheritDoc ComponentBuilder.toJSON}
*/
public spliceActionRows(
index: number,
deleteCount: number,
...rows: (
| ActionRowBuilder
| APIActionRowComponent<APIModalActionRowComponent>
| ((builder: ActionRowBuilder) => ActionRowBuilder)
)[]
): this {
const resolved = rows.map((row) => resolveBuilder(row, ActionRowBuilder));
this.data.components.splice(index, deleteCount, ...resolved);
public toJSON(): APIModalInteractionResponseCallbackData {
validateRequiredParameters(this.data.custom_id, this.data.title, this.components);
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* Note that by disabling validation, there is no guarantee that the resulting object will be valid.
*
* @param validationOverride - Force validation to run/not run regardless of your global preference
*/
public toJSON(validationOverride?: boolean): APIModalInteractionResponseCallbackData {
const { components, ...rest } = this.data;
const data = {
...structuredClone(rest),
components: components.map((component) => component.toJSON(validationOverride)),
};
validate(modalPredicate, data, validationOverride);
return data as APIModalInteractionResponseCallbackData;
return {
...this.data,
components: this.components.map((component) => component.toJSON()),
} as APIModalInteractionResponseCallbackData;
}
}

View File

@@ -0,0 +1,123 @@
import { s } from '@sapphire/shapeshift';
import {
ApplicationIntegrationType,
InteractionContextType,
Locale,
type APIApplicationCommandOptionChoice,
type LocalizationMap,
} from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import type { ToAPIApplicationCommandOptions } from './SlashCommandBuilder.js';
import type { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder } from './SlashCommandSubcommands.js';
import type { ApplicationCommandOptionBase } from './mixins/ApplicationCommandOptionBase.js';
const namePredicate = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(32)
.regex(/^[\p{Ll}\p{Lm}\p{Lo}\p{N}\p{sc=Devanagari}\p{sc=Thai}_-]+$/u)
.setValidationEnabled(isValidationEnabled);
export function validateName(name: unknown): asserts name is string {
namePredicate.parse(name);
}
const descriptionPredicate = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(100)
.setValidationEnabled(isValidationEnabled);
const localePredicate = s.nativeEnum(Locale);
export function validateDescription(description: unknown): asserts description is string {
descriptionPredicate.parse(description);
}
const maxArrayLengthPredicate = s.unknown().array().lengthLessThanOrEqual(25).setValidationEnabled(isValidationEnabled);
export function validateLocale(locale: unknown) {
return localePredicate.parse(locale);
}
export function validateMaxOptionsLength(options: unknown): asserts options is ToAPIApplicationCommandOptions[] {
maxArrayLengthPredicate.parse(options);
}
export function validateRequiredParameters(
name: string,
description: string,
options: ToAPIApplicationCommandOptions[],
) {
// Assert name matches all conditions
validateName(name);
// Assert description conditions
validateDescription(description);
// Assert options conditions
validateMaxOptionsLength(options);
}
const booleanPredicate = s.boolean();
export function validateDefaultPermission(value: unknown): asserts value is boolean {
booleanPredicate.parse(value);
}
export function validateRequired(required: unknown): asserts required is boolean {
booleanPredicate.parse(required);
}
const choicesLengthPredicate = s.number().lessThanOrEqual(25).setValidationEnabled(isValidationEnabled);
export function validateChoicesLength(amountAdding: number, choices?: APIApplicationCommandOptionChoice[]): void {
choicesLengthPredicate.parse((choices?.length ?? 0) + amountAdding);
}
export function assertReturnOfBuilder<
ReturnType extends ApplicationCommandOptionBase | SlashCommandSubcommandBuilder | SlashCommandSubcommandGroupBuilder,
>(input: unknown, ExpectedInstanceOf: new () => ReturnType): asserts input is ReturnType {
s.instance(ExpectedInstanceOf).parse(input);
}
export const localizationMapPredicate = s
.object<LocalizationMap>(Object.fromEntries(Object.values(Locale).map((locale) => [locale, s.string().nullish()])))
.strict()
.nullish()
.setValidationEnabled(isValidationEnabled);
export function validateLocalizationMap(value: unknown): asserts value is LocalizationMap {
localizationMapPredicate.parse(value);
}
const dmPermissionPredicate = s.boolean().nullish();
export function validateDMPermission(value: unknown): asserts value is boolean | null | undefined {
dmPermissionPredicate.parse(value);
}
const memberPermissionPredicate = s
.union([
s.bigint().transform((value) => value.toString()),
s
.number()
.safeInt()
.transform((value) => value.toString()),
s.string().regex(/^\d+$/),
])
.nullish();
export function validateDefaultMemberPermissions(permissions: unknown) {
return memberPermissionPredicate.parse(permissions);
}
export function validateNSFW(value: unknown): asserts value is boolean {
booleanPredicate.parse(value);
}
export const contextsPredicate = s.array(
s.nativeEnum(InteractionContextType).setValidationEnabled(isValidationEnabled),
);
export const integrationTypesPredicate = s.array(
s.nativeEnum(ApplicationIntegrationType).setValidationEnabled(isValidationEnabled),
);

View File

@@ -0,0 +1,110 @@
import type {
APIApplicationCommandOption,
ApplicationIntegrationType,
InteractionContextType,
LocalizationMap,
Permissions,
} from 'discord-api-types/v10';
import { mix } from 'ts-mixer';
import { SharedNameAndDescription } from './mixins/NameAndDescription.js';
import { SharedSlashCommand } from './mixins/SharedSlashCommand.js';
import { SharedSlashCommandOptions } from './mixins/SharedSlashCommandOptions.js';
import { SharedSlashCommandSubcommands } from './mixins/SharedSubcommands.js';
/**
* A builder that creates API-compatible JSON data for slash commands.
*/
@mix(SharedSlashCommandOptions, SharedNameAndDescription, SharedSlashCommandSubcommands, SharedSlashCommand)
export class SlashCommandBuilder {
/**
* The name of this command.
*/
public readonly name: string = undefined!;
/**
* The name localizations of this command.
*/
public readonly name_localizations?: LocalizationMap;
/**
* The description of this command.
*/
public readonly description: string = undefined!;
/**
* The description localizations of this command.
*/
public readonly description_localizations?: LocalizationMap;
/**
* The options of this command.
*/
public readonly options: ToAPIApplicationCommandOptions[] = [];
/**
* The contexts for this command.
*/
public readonly contexts?: InteractionContextType[];
/**
* Whether this command is enabled by default when the application is added to a guild.
*
* @deprecated Use {@link SharedSlashCommand.setDefaultMemberPermissions} or {@link SharedSlashCommand.setDMPermission} instead.
*/
public readonly default_permission: boolean | undefined = undefined;
/**
* The set of permissions represented as a bit set for the command.
*/
public readonly default_member_permissions: Permissions | null | undefined = undefined;
/**
* Indicates whether the command is available in direct messages with the application.
*
* @remarks
* By default, commands are visible. This property is only for global commands.
* @deprecated
* Use {@link SlashCommandBuilder.contexts} instead.
*/
public readonly dm_permission: boolean | undefined = undefined;
/**
* The integration types for this command.
*/
public readonly integration_types?: ApplicationIntegrationType[];
/**
* Whether this command is NSFW.
*/
public readonly nsfw: boolean | undefined = undefined;
}
export interface SlashCommandBuilder
extends SharedNameAndDescription,
SharedSlashCommandOptions<SlashCommandOptionsOnlyBuilder>,
SharedSlashCommandSubcommands<SlashCommandSubcommandsOnlyBuilder>,
SharedSlashCommand {}
/**
* An interface specifically for slash command subcommands.
*/
export interface SlashCommandSubcommandsOnlyBuilder
extends SharedNameAndDescription,
SharedSlashCommandSubcommands<SlashCommandSubcommandsOnlyBuilder>,
SharedSlashCommand {}
/**
* An interface specifically for slash command options.
*/
export interface SlashCommandOptionsOnlyBuilder
extends SharedNameAndDescription,
SharedSlashCommandOptions<SlashCommandOptionsOnlyBuilder>,
SharedSlashCommand {}
/**
* An interface that ensures the `toJSON()` call will return something
* that can be serialized into API-compatible data.
*/
export interface ToAPIApplicationCommandOptions {
toJSON(): APIApplicationCommandOption;
}

View File

@@ -0,0 +1,131 @@
import {
ApplicationCommandOptionType,
type APIApplicationCommandSubcommandGroupOption,
type APIApplicationCommandSubcommandOption,
} from 'discord-api-types/v10';
import { mix } from 'ts-mixer';
import { assertReturnOfBuilder, validateMaxOptionsLength, validateRequiredParameters } from './Assertions.js';
import type { ToAPIApplicationCommandOptions } from './SlashCommandBuilder.js';
import type { ApplicationCommandOptionBase } from './mixins/ApplicationCommandOptionBase.js';
import { SharedNameAndDescription } from './mixins/NameAndDescription.js';
import { SharedSlashCommandOptions } from './mixins/SharedSlashCommandOptions.js';
/**
* Represents a folder for subcommands.
*
* @see {@link https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups}
*/
@mix(SharedNameAndDescription)
export class SlashCommandSubcommandGroupBuilder implements ToAPIApplicationCommandOptions {
/**
* The name of this subcommand group.
*/
public readonly name: string = undefined!;
/**
* The description of this subcommand group.
*/
public readonly description: string = undefined!;
/**
* The subcommands within this subcommand group.
*/
public readonly options: SlashCommandSubcommandBuilder[] = [];
/**
* Adds a new subcommand to this group.
*
* @param input - A function that returns a subcommand builder or an already built builder
*/
public addSubcommand(
input:
| SlashCommandSubcommandBuilder
| ((subcommandGroup: SlashCommandSubcommandBuilder) => SlashCommandSubcommandBuilder),
) {
const { options } = this;
// First, assert options conditions - we cannot have more than 25 options
validateMaxOptionsLength(options);
// Get the final result
// eslint-disable-next-line @typescript-eslint/no-use-before-define
const result = typeof input === 'function' ? input(new SlashCommandSubcommandBuilder()) : input;
// eslint-disable-next-line @typescript-eslint/no-use-before-define
assertReturnOfBuilder(result, SlashCommandSubcommandBuilder);
// Push it
options.push(result);
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* @remarks
* This method runs validations on the data before serializing it.
* As such, it may throw an error if the data is invalid.
*/
public toJSON(): APIApplicationCommandSubcommandGroupOption {
validateRequiredParameters(this.name, this.description, this.options);
return {
type: ApplicationCommandOptionType.SubcommandGroup,
name: this.name,
name_localizations: this.name_localizations,
description: this.description,
description_localizations: this.description_localizations,
options: this.options.map((option) => option.toJSON()),
};
}
}
export interface SlashCommandSubcommandGroupBuilder extends SharedNameAndDescription {}
/**
* A builder that creates API-compatible JSON data for slash command subcommands.
*
* @see {@link https://discord.com/developers/docs/interactions/application-commands#subcommands-and-subcommand-groups}
*/
@mix(SharedNameAndDescription, SharedSlashCommandOptions)
export class SlashCommandSubcommandBuilder implements ToAPIApplicationCommandOptions {
/**
* The name of this subcommand.
*/
public readonly name: string = undefined!;
/**
* The description of this subcommand.
*/
public readonly description: string = undefined!;
/**
* The options within this subcommand.
*/
public readonly options: ApplicationCommandOptionBase[] = [];
/**
* Serializes this builder to API-compatible JSON data.
*
* @remarks
* This method runs validations on the data before serializing it.
* As such, it may throw an error if the data is invalid.
*/
public toJSON(): APIApplicationCommandSubcommandOption {
validateRequiredParameters(this.name, this.description, this.options);
return {
type: ApplicationCommandOptionType.Subcommand,
name: this.name,
name_localizations: this.name_localizations,
description: this.description,
description_localizations: this.description_localizations,
options: this.options.map((option) => option.toJSON()),
};
}
}
export interface SlashCommandSubcommandBuilder
extends SharedNameAndDescription,
SharedSlashCommandOptions<SlashCommandSubcommandBuilder> {}

View File

@@ -0,0 +1,28 @@
/**
* This mixin holds minimum and maximum symbols used for options.
*/
export abstract class ApplicationCommandNumericOptionMinMaxValueMixin {
/**
* The maximum value of this option.
*/
public readonly max_value?: number;
/**
* The minimum value of this option.
*/
public readonly min_value?: number;
/**
* Sets the maximum number value of this option.
*
* @param max - The maximum value this option can be
*/
public abstract setMaxValue(max: number): this;
/**
* Sets the minimum number value of this option.
*
* @param min - The minimum value this option can be
*/
public abstract setMinValue(min: number): this;
}

View File

@@ -0,0 +1,57 @@
import type { APIApplicationCommandBasicOption, ApplicationCommandOptionType } from 'discord-api-types/v10';
import { validateRequiredParameters, validateRequired, validateLocalizationMap } from '../Assertions.js';
import { SharedNameAndDescription } from './NameAndDescription.js';
/**
* The base application command option builder that contains common symbols for application command builders.
*/
export abstract class ApplicationCommandOptionBase extends SharedNameAndDescription {
/**
* The type of this option.
*/
public abstract readonly type: ApplicationCommandOptionType;
/**
* Whether this option is required.
*
* @defaultValue `false`
*/
public readonly required: boolean = false;
/**
* Sets whether this option is required.
*
* @param required - Whether this option should be required
*/
public setRequired(required: boolean) {
// Assert that you actually passed a boolean
validateRequired(required);
Reflect.set(this, 'required', required);
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* @remarks
* This method runs validations on the data before serializing it.
* As such, it may throw an error if the data is invalid.
*/
public abstract toJSON(): APIApplicationCommandBasicOption;
/**
* This method runs required validators on this builder.
*/
protected runRequiredValidations() {
validateRequiredParameters(this.name, this.description, []);
// Validate localizations
validateLocalizationMap(this.name_localizations);
validateLocalizationMap(this.description_localizations);
// Assert that you actually passed a boolean
validateRequired(this.required);
}
}

View File

@@ -0,0 +1,54 @@
import { s } from '@sapphire/shapeshift';
import { ChannelType } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray';
/**
* The allowed channel types used for a channel option in a slash command builder.
*
* @privateRemarks This can't be dynamic because const enums are erased at runtime.
* @internal
*/
const allowedChannelTypes = [
ChannelType.GuildText,
ChannelType.GuildVoice,
ChannelType.GuildCategory,
ChannelType.GuildAnnouncement,
ChannelType.AnnouncementThread,
ChannelType.PublicThread,
ChannelType.PrivateThread,
ChannelType.GuildStageVoice,
ChannelType.GuildForum,
ChannelType.GuildMedia,
] as const;
/**
* The type of allowed channel types used for a channel option.
*/
export type ApplicationCommandOptionAllowedChannelTypes = (typeof allowedChannelTypes)[number];
const channelTypesPredicate = s.array(s.union(allowedChannelTypes.map((type) => s.literal(type))));
/**
* This mixin holds channel type symbols used for options.
*/
export class ApplicationCommandOptionChannelTypesMixin {
/**
* The channel types of this option.
*/
public readonly channel_types?: ApplicationCommandOptionAllowedChannelTypes[];
/**
* Adds channel types to this option.
*
* @param channelTypes - The channel types
*/
public addChannelTypes(...channelTypes: RestOrArray<ApplicationCommandOptionAllowedChannelTypes>) {
if (this.channel_types === undefined) {
Reflect.set(this, 'channel_types', []);
}
this.channel_types!.push(...channelTypesPredicate.parse(normalizeArray(channelTypes)));
return this;
}
}

View File

@@ -0,0 +1,39 @@
import { s } from '@sapphire/shapeshift';
import type { ApplicationCommandOptionType } from 'discord-api-types/v10';
const booleanPredicate = s.boolean();
/**
* This mixin holds choices and autocomplete symbols used for options.
*/
export class ApplicationCommandOptionWithAutocompleteMixin {
/**
* Whether this option utilizes autocomplete.
*/
public readonly autocomplete?: boolean;
/**
* The type of this option.
*
* @privateRemarks Since this is present and this is a mixin, this is needed.
*/
public readonly type!: ApplicationCommandOptionType;
/**
* Whether this option uses autocomplete.
*
* @param autocomplete - Whether this option should use autocomplete
*/
public setAutocomplete(autocomplete: boolean): this {
// Assert that you actually passed a boolean
booleanPredicate.parse(autocomplete);
if (autocomplete && 'choices' in this && Array.isArray(this.choices) && this.choices.length > 0) {
throw new RangeError('Autocomplete and choices are mutually exclusive to each other.');
}
Reflect.set(this, 'autocomplete', autocomplete);
return this;
}
}

View File

@@ -0,0 +1,83 @@
import { s } from '@sapphire/shapeshift';
import { ApplicationCommandOptionType, type APIApplicationCommandOptionChoice } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../../../util/normalizeArray.js';
import { localizationMapPredicate, validateChoicesLength } from '../Assertions.js';
const stringPredicate = s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100);
const numberPredicate = s.number().greaterThan(Number.NEGATIVE_INFINITY).lessThan(Number.POSITIVE_INFINITY);
const choicesPredicate = s
.object({
name: stringPredicate,
name_localizations: localizationMapPredicate,
value: s.union([stringPredicate, numberPredicate]),
})
.array();
/**
* This mixin holds choices and autocomplete symbols used for options.
*/
export class ApplicationCommandOptionWithChoicesMixin<ChoiceType extends number | string> {
/**
* The choices of this option.
*/
public readonly choices?: APIApplicationCommandOptionChoice<ChoiceType>[];
/**
* The type of this option.
*
* @privateRemarks Since this is present and this is a mixin, this is needed.
*/
public readonly type!: ApplicationCommandOptionType;
/**
* Adds multiple choices to this option.
*
* @param choices - The choices to add
*/
public addChoices(...choices: RestOrArray<APIApplicationCommandOptionChoice<ChoiceType>>): this {
const normalizedChoices = normalizeArray(choices);
if (normalizedChoices.length > 0 && 'autocomplete' in this && this.autocomplete) {
throw new RangeError('Autocomplete and choices are mutually exclusive to each other.');
}
choicesPredicate.parse(normalizedChoices);
if (this.choices === undefined) {
Reflect.set(this, 'choices', []);
}
validateChoicesLength(normalizedChoices.length, this.choices);
for (const { name, name_localizations, value } of normalizedChoices) {
// Validate the value
if (this.type === ApplicationCommandOptionType.String) {
stringPredicate.parse(value);
} else {
numberPredicate.parse(value);
}
this.choices!.push({ name, name_localizations, value });
}
return this;
}
/**
* Sets multiple choices for this option.
*
* @param choices - The choices to set
*/
public setChoices<Input extends APIApplicationCommandOptionChoice<ChoiceType>>(...choices: RestOrArray<Input>): this {
const normalizedChoices = normalizeArray(choices);
if (normalizedChoices.length > 0 && 'autocomplete' in this && this.autocomplete) {
throw new RangeError('Autocomplete and choices are mutually exclusive to each other.');
}
choicesPredicate.parse(normalizedChoices);
Reflect.set(this, 'choices', []);
this.addChoices(normalizedChoices);
return this;
}
}

View File

@@ -0,0 +1,142 @@
import type { LocaleString, LocalizationMap } from 'discord-api-types/v10';
import { validateDescription, validateLocale, validateName } from '../Assertions.js';
/**
* This mixin holds name and description symbols for slash commands.
*/
export class SharedNameAndDescription {
/**
* The name of this command.
*/
public readonly name!: string;
/**
* The name localizations of this command.
*/
public readonly name_localizations?: LocalizationMap;
/**
* The description of this command.
*/
public readonly description!: string;
/**
* The description localizations of this command.
*/
public readonly description_localizations?: LocalizationMap;
/**
* Sets the name of this command.
*
* @param name - The name to use
*/
public setName(name: string): this {
// Assert the name matches the conditions
validateName(name);
Reflect.set(this, 'name', name);
return this;
}
/**
* Sets the description of this command.
*
* @param description - The description to use
*/
public setDescription(description: string) {
// Assert the description matches the conditions
validateDescription(description);
Reflect.set(this, 'description', description);
return this;
}
/**
* Sets a name localization for this command.
*
* @param locale - The locale to set
* @param localizedName - The localized name for the given `locale`
*/
public setNameLocalization(locale: LocaleString, localizedName: string | null) {
if (!this.name_localizations) {
Reflect.set(this, 'name_localizations', {});
}
const parsedLocale = validateLocale(locale);
if (localizedName === null) {
this.name_localizations![parsedLocale] = null;
return this;
}
validateName(localizedName);
this.name_localizations![parsedLocale] = localizedName;
return this;
}
/**
* Sets the name localizations for this command.
*
* @param localizedNames - The object of localized names to set
*/
public setNameLocalizations(localizedNames: LocalizationMap | null) {
if (localizedNames === null) {
Reflect.set(this, 'name_localizations', null);
return this;
}
Reflect.set(this, 'name_localizations', {});
for (const args of Object.entries(localizedNames)) {
this.setNameLocalization(...(args as [LocaleString, string | null]));
}
return this;
}
/**
* Sets a description localization for this command.
*
* @param locale - The locale to set
* @param localizedDescription - The localized description for the given locale
*/
public setDescriptionLocalization(locale: LocaleString, localizedDescription: string | null) {
if (!this.description_localizations) {
Reflect.set(this, 'description_localizations', {});
}
const parsedLocale = validateLocale(locale);
if (localizedDescription === null) {
this.description_localizations![parsedLocale] = null;
return this;
}
validateDescription(localizedDescription);
this.description_localizations![parsedLocale] = localizedDescription;
return this;
}
/**
* Sets the description localizations for this command.
*
* @param localizedDescriptions - The object of localized descriptions to set
*/
public setDescriptionLocalizations(localizedDescriptions: LocalizationMap | null) {
if (localizedDescriptions === null) {
Reflect.set(this, 'description_localizations', null);
return this;
}
Reflect.set(this, 'description_localizations', {});
for (const args of Object.entries(localizedDescriptions)) {
this.setDescriptionLocalization(...(args as [LocaleString, string | null]));
}
return this;
}
}

View File

@@ -0,0 +1,162 @@
import {
ApplicationCommandType,
type ApplicationIntegrationType,
type InteractionContextType,
type LocalizationMap,
type Permissions,
type RESTPostAPIChatInputApplicationCommandsJSONBody,
} from 'discord-api-types/v10';
import type { RestOrArray } from '../../../util/normalizeArray.js';
import { normalizeArray } from '../../../util/normalizeArray.js';
import {
contextsPredicate,
integrationTypesPredicate,
validateDMPermission,
validateDefaultMemberPermissions,
validateDefaultPermission,
validateLocalizationMap,
validateNSFW,
validateRequiredParameters,
} from '../Assertions.js';
import type { ToAPIApplicationCommandOptions } from '../SlashCommandBuilder.js';
/**
* This mixin holds symbols that can be shared in slashcommands independent of options or subcommands.
*/
export class SharedSlashCommand {
public readonly name: string = undefined!;
public readonly name_localizations?: LocalizationMap;
public readonly description: string = undefined!;
public readonly description_localizations?: LocalizationMap;
public readonly options: ToAPIApplicationCommandOptions[] = [];
public readonly contexts?: InteractionContextType[];
/**
* @deprecated Use {@link SharedSlashCommand.setDefaultMemberPermissions} or {@link SharedSlashCommand.setDMPermission} instead.
*/
public readonly default_permission: boolean | undefined = undefined;
public readonly default_member_permissions: Permissions | null | undefined = undefined;
/**
* @deprecated Use {@link SharedSlashCommand.contexts} instead.
*/
public readonly dm_permission: boolean | undefined = undefined;
public readonly integration_types?: ApplicationIntegrationType[];
public readonly nsfw: boolean | undefined = undefined;
/**
* Sets the contexts of this command.
*
* @param contexts - The contexts
*/
public setContexts(...contexts: RestOrArray<InteractionContextType>) {
Reflect.set(this, 'contexts', contextsPredicate.parse(normalizeArray(contexts)));
return this;
}
/**
* Sets the integration types of this command.
*
* @param integrationTypes - The integration types
*/
public setIntegrationTypes(...integrationTypes: RestOrArray<ApplicationIntegrationType>) {
Reflect.set(this, 'integration_types', integrationTypesPredicate.parse(normalizeArray(integrationTypes)));
return this;
}
/**
* Sets whether the command is enabled by default when the application is added to a guild.
*
* @remarks
* If set to `false`, you will have to later `PUT` the permissions for this command.
* @param value - Whether or not to enable this command by default
* @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions}
* @deprecated Use {@link SharedSlashCommand.setDefaultMemberPermissions} or {@link SharedSlashCommand.setDMPermission} instead.
*/
public setDefaultPermission(value: boolean) {
// Assert the value matches the conditions
validateDefaultPermission(value);
Reflect.set(this, 'default_permission', value);
return this;
}
/**
* Sets the default permissions a member should have in order to run the command.
*
* @remarks
* You can set this to `'0'` to disable the command by default.
* @param permissions - The permissions bit field to set
* @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions}
*/
public setDefaultMemberPermissions(permissions: Permissions | bigint | number | null | undefined) {
// Assert the value and parse it
const permissionValue = validateDefaultMemberPermissions(permissions);
Reflect.set(this, 'default_member_permissions', permissionValue);
return this;
}
/**
* Sets if the command is available in direct messages with the application.
*
* @remarks
* By default, commands are visible. This method is only for global commands.
* @param enabled - Whether the command should be enabled in direct messages
* @see {@link https://discord.com/developers/docs/interactions/application-commands#permissions}
* @deprecated
* Use {@link SharedSlashCommand.setContexts} instead.
*/
public setDMPermission(enabled: boolean | null | undefined) {
// Assert the value matches the conditions
validateDMPermission(enabled);
Reflect.set(this, 'dm_permission', enabled);
return this;
}
/**
* Sets whether this command is NSFW.
*
* @param nsfw - Whether this command is NSFW
*/
public setNSFW(nsfw = true) {
// Assert the value matches the conditions
validateNSFW(nsfw);
Reflect.set(this, 'nsfw', nsfw);
return this;
}
/**
* Serializes this builder to API-compatible JSON data.
*
* @remarks
* This method runs validations on the data before serializing it.
* As such, it may throw an error if the data is invalid.
*/
public toJSON(): RESTPostAPIChatInputApplicationCommandsJSONBody {
validateRequiredParameters(this.name, this.description, this.options);
validateLocalizationMap(this.name_localizations);
validateLocalizationMap(this.description_localizations);
return {
...this,
type: ApplicationCommandType.ChatInput,
options: this.options.map((option) => option.toJSON()),
};
}
}

View File

@@ -0,0 +1,145 @@
import { assertReturnOfBuilder, validateMaxOptionsLength } from '../Assertions.js';
import type { ToAPIApplicationCommandOptions } from '../SlashCommandBuilder';
import { SlashCommandAttachmentOption } from '../options/attachment.js';
import { SlashCommandBooleanOption } from '../options/boolean.js';
import { SlashCommandChannelOption } from '../options/channel.js';
import { SlashCommandIntegerOption } from '../options/integer.js';
import { SlashCommandMentionableOption } from '../options/mentionable.js';
import { SlashCommandNumberOption } from '../options/number.js';
import { SlashCommandRoleOption } from '../options/role.js';
import { SlashCommandStringOption } from '../options/string.js';
import { SlashCommandUserOption } from '../options/user.js';
import type { ApplicationCommandOptionBase } from './ApplicationCommandOptionBase.js';
/**
* This mixin holds symbols that can be shared in slash command options.
*
* @typeParam TypeAfterAddingOptions - The type this class should return after adding an option.
*/
export class SharedSlashCommandOptions<
TypeAfterAddingOptions extends SharedSlashCommandOptions<TypeAfterAddingOptions>,
> {
public readonly options!: ToAPIApplicationCommandOptions[];
/**
* Adds a boolean option.
*
* @param input - A function that returns an option builder or an already built builder
*/
public addBooleanOption(
input: SlashCommandBooleanOption | ((builder: SlashCommandBooleanOption) => SlashCommandBooleanOption),
) {
return this._sharedAddOptionMethod(input, SlashCommandBooleanOption);
}
/**
* Adds a user option.
*
* @param input - A function that returns an option builder or an already built builder
*/
public addUserOption(input: SlashCommandUserOption | ((builder: SlashCommandUserOption) => SlashCommandUserOption)) {
return this._sharedAddOptionMethod(input, SlashCommandUserOption);
}
/**
* Adds a channel option.
*
* @param input - A function that returns an option builder or an already built builder
*/
public addChannelOption(
input: SlashCommandChannelOption | ((builder: SlashCommandChannelOption) => SlashCommandChannelOption),
) {
return this._sharedAddOptionMethod(input, SlashCommandChannelOption);
}
/**
* Adds a role option.
*
* @param input - A function that returns an option builder or an already built builder
*/
public addRoleOption(input: SlashCommandRoleOption | ((builder: SlashCommandRoleOption) => SlashCommandRoleOption)) {
return this._sharedAddOptionMethod(input, SlashCommandRoleOption);
}
/**
* Adds an attachment option.
*
* @param input - A function that returns an option builder or an already built builder
*/
public addAttachmentOption(
input: SlashCommandAttachmentOption | ((builder: SlashCommandAttachmentOption) => SlashCommandAttachmentOption),
) {
return this._sharedAddOptionMethod(input, SlashCommandAttachmentOption);
}
/**
* Adds a mentionable option.
*
* @param input - A function that returns an option builder or an already built builder
*/
public addMentionableOption(
input: SlashCommandMentionableOption | ((builder: SlashCommandMentionableOption) => SlashCommandMentionableOption),
) {
return this._sharedAddOptionMethod(input, SlashCommandMentionableOption);
}
/**
* Adds a string option.
*
* @param input - A function that returns an option builder or an already built builder
*/
public addStringOption(
input: SlashCommandStringOption | ((builder: SlashCommandStringOption) => SlashCommandStringOption),
) {
return this._sharedAddOptionMethod(input, SlashCommandStringOption);
}
/**
* Adds an integer option.
*
* @param input - A function that returns an option builder or an already built builder
*/
public addIntegerOption(
input: SlashCommandIntegerOption | ((builder: SlashCommandIntegerOption) => SlashCommandIntegerOption),
) {
return this._sharedAddOptionMethod(input, SlashCommandIntegerOption);
}
/**
* Adds a number option.
*
* @param input - A function that returns an option builder or an already built builder
*/
public addNumberOption(
input: SlashCommandNumberOption | ((builder: SlashCommandNumberOption) => SlashCommandNumberOption),
) {
return this._sharedAddOptionMethod(input, SlashCommandNumberOption);
}
/**
* Where the actual adding magic happens. ✨
*
* @param input - The input. What else?
* @param Instance - The instance of whatever is being added
* @internal
*/
private _sharedAddOptionMethod<OptionBuilder extends ApplicationCommandOptionBase>(
input: OptionBuilder | ((builder: OptionBuilder) => OptionBuilder),
Instance: new () => OptionBuilder,
): TypeAfterAddingOptions {
const { options } = this;
// First, assert options conditions - we cannot have more than 25 options
validateMaxOptionsLength(options);
// Get the final result
const result = typeof input === 'function' ? input(new Instance()) : input;
assertReturnOfBuilder(result, Instance);
// Push it
options.push(result);
return this as unknown as TypeAfterAddingOptions;
}
}

View File

@@ -0,0 +1,66 @@
import { assertReturnOfBuilder, validateMaxOptionsLength } from '../Assertions.js';
import type { ToAPIApplicationCommandOptions } from '../SlashCommandBuilder.js';
import { SlashCommandSubcommandBuilder, SlashCommandSubcommandGroupBuilder } from '../SlashCommandSubcommands.js';
/**
* This mixin holds symbols that can be shared in slash subcommands.
*
* @typeParam TypeAfterAddingSubcommands - The type this class should return after adding a subcommand or subcommand group.
*/
export class SharedSlashCommandSubcommands<
TypeAfterAddingSubcommands extends SharedSlashCommandSubcommands<TypeAfterAddingSubcommands>,
> {
public readonly options: ToAPIApplicationCommandOptions[] = [];
/**
* Adds a new subcommand group to this command.
*
* @param input - A function that returns a subcommand group builder or an already built builder
*/
public addSubcommandGroup(
input:
| SlashCommandSubcommandGroupBuilder
| ((subcommandGroup: SlashCommandSubcommandGroupBuilder) => SlashCommandSubcommandGroupBuilder),
): TypeAfterAddingSubcommands {
const { options } = this;
// First, assert options conditions - we cannot have more than 25 options
validateMaxOptionsLength(options);
// Get the final result
const result = typeof input === 'function' ? input(new SlashCommandSubcommandGroupBuilder()) : input;
assertReturnOfBuilder(result, SlashCommandSubcommandGroupBuilder);
// Push it
options.push(result);
return this as unknown as TypeAfterAddingSubcommands;
}
/**
* Adds a new subcommand to this command.
*
* @param input - A function that returns a subcommand builder or an already built builder
*/
public addSubcommand(
input:
| SlashCommandSubcommandBuilder
| ((subcommandGroup: SlashCommandSubcommandBuilder) => SlashCommandSubcommandBuilder),
): TypeAfterAddingSubcommands {
const { options } = this;
// First, assert options conditions - we cannot have more than 25 options
validateMaxOptionsLength(options);
// Get the final result
const result = typeof input === 'function' ? input(new SlashCommandSubcommandBuilder()) : input;
assertReturnOfBuilder(result, SlashCommandSubcommandBuilder);
// Push it
options.push(result);
return this as unknown as TypeAfterAddingSubcommands;
}
}

View File

@@ -0,0 +1,21 @@
import { ApplicationCommandOptionType, type APIApplicationCommandAttachmentOption } from 'discord-api-types/v10';
import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js';
/**
* A slash command attachment option.
*/
export class SlashCommandAttachmentOption extends ApplicationCommandOptionBase {
/**
* The type of this option.
*/
public override readonly type = ApplicationCommandOptionType.Attachment as const;
/**
* {@inheritDoc ApplicationCommandOptionBase.toJSON}
*/
public toJSON(): APIApplicationCommandAttachmentOption {
this.runRequiredValidations();
return { ...this };
}
}

View File

@@ -0,0 +1,21 @@
import { ApplicationCommandOptionType, type APIApplicationCommandBooleanOption } from 'discord-api-types/v10';
import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js';
/**
* A slash command boolean option.
*/
export class SlashCommandBooleanOption extends ApplicationCommandOptionBase {
/**
* The type of this option.
*/
public readonly type = ApplicationCommandOptionType.Boolean as const;
/**
* {@inheritDoc ApplicationCommandOptionBase.toJSON}
*/
public toJSON(): APIApplicationCommandBooleanOption {
this.runRequiredValidations();
return { ...this };
}
}

View File

@@ -0,0 +1,26 @@
import { ApplicationCommandOptionType, type APIApplicationCommandChannelOption } from 'discord-api-types/v10';
import { mix } from 'ts-mixer';
import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js';
import { ApplicationCommandOptionChannelTypesMixin } from '../mixins/ApplicationCommandOptionChannelTypesMixin.js';
/**
* A slash command channel option.
*/
@mix(ApplicationCommandOptionChannelTypesMixin)
export class SlashCommandChannelOption extends ApplicationCommandOptionBase {
/**
* The type of this option.
*/
public override readonly type = ApplicationCommandOptionType.Channel as const;
/**
* {@inheritDoc ApplicationCommandOptionBase.toJSON}
*/
public toJSON(): APIApplicationCommandChannelOption {
this.runRequiredValidations();
return { ...this };
}
}
export interface SlashCommandChannelOption extends ApplicationCommandOptionChannelTypesMixin {}

View File

@@ -0,0 +1,67 @@
import { s } from '@sapphire/shapeshift';
import { ApplicationCommandOptionType, type APIApplicationCommandIntegerOption } from 'discord-api-types/v10';
import { mix } from 'ts-mixer';
import { ApplicationCommandNumericOptionMinMaxValueMixin } from '../mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js';
import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js';
import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js';
import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js';
const numberValidator = s.number().int();
/**
* A slash command integer option.
*/
@mix(
ApplicationCommandNumericOptionMinMaxValueMixin,
ApplicationCommandOptionWithAutocompleteMixin,
ApplicationCommandOptionWithChoicesMixin,
)
export class SlashCommandIntegerOption
extends ApplicationCommandOptionBase
implements ApplicationCommandNumericOptionMinMaxValueMixin
{
/**
* The type of this option.
*/
public readonly type = ApplicationCommandOptionType.Integer as const;
/**
* {@inheritDoc ApplicationCommandNumericOptionMinMaxValueMixin.setMaxValue}
*/
public setMaxValue(max: number): this {
numberValidator.parse(max);
Reflect.set(this, 'max_value', max);
return this;
}
/**
* {@inheritDoc ApplicationCommandNumericOptionMinMaxValueMixin.setMinValue}
*/
public setMinValue(min: number): this {
numberValidator.parse(min);
Reflect.set(this, 'min_value', min);
return this;
}
/**
* {@inheritDoc ApplicationCommandOptionBase.toJSON}
*/
public toJSON(): APIApplicationCommandIntegerOption {
this.runRequiredValidations();
if (this.autocomplete && Array.isArray(this.choices) && this.choices.length > 0) {
throw new RangeError('Autocomplete and choices are mutually exclusive to each other.');
}
return { ...this } as APIApplicationCommandIntegerOption;
}
}
export interface SlashCommandIntegerOption
extends ApplicationCommandNumericOptionMinMaxValueMixin,
ApplicationCommandOptionWithChoicesMixin<number>,
ApplicationCommandOptionWithAutocompleteMixin {}

View File

@@ -0,0 +1,21 @@
import { ApplicationCommandOptionType, type APIApplicationCommandMentionableOption } from 'discord-api-types/v10';
import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js';
/**
* A slash command mentionable option.
*/
export class SlashCommandMentionableOption extends ApplicationCommandOptionBase {
/**
* The type of this option.
*/
public readonly type = ApplicationCommandOptionType.Mentionable as const;
/**
* {@inheritDoc ApplicationCommandOptionBase.toJSON}
*/
public toJSON(): APIApplicationCommandMentionableOption {
this.runRequiredValidations();
return { ...this };
}
}

View File

@@ -0,0 +1,67 @@
import { s } from '@sapphire/shapeshift';
import { ApplicationCommandOptionType, type APIApplicationCommandNumberOption } from 'discord-api-types/v10';
import { mix } from 'ts-mixer';
import { ApplicationCommandNumericOptionMinMaxValueMixin } from '../mixins/ApplicationCommandNumericOptionMinMaxValueMixin.js';
import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js';
import { ApplicationCommandOptionWithAutocompleteMixin } from '../mixins/ApplicationCommandOptionWithAutocompleteMixin.js';
import { ApplicationCommandOptionWithChoicesMixin } from '../mixins/ApplicationCommandOptionWithChoicesMixin.js';
const numberValidator = s.number();
/**
* A slash command number option.
*/
@mix(
ApplicationCommandNumericOptionMinMaxValueMixin,
ApplicationCommandOptionWithAutocompleteMixin,
ApplicationCommandOptionWithChoicesMixin,
)
export class SlashCommandNumberOption
extends ApplicationCommandOptionBase
implements ApplicationCommandNumericOptionMinMaxValueMixin
{
/**
* The type of this option.
*/
public readonly type = ApplicationCommandOptionType.Number as const;
/**
* {@inheritDoc ApplicationCommandNumericOptionMinMaxValueMixin.setMaxValue}
*/
public setMaxValue(max: number): this {
numberValidator.parse(max);
Reflect.set(this, 'max_value', max);
return this;
}
/**
* {@inheritDoc ApplicationCommandNumericOptionMinMaxValueMixin.setMinValue}
*/
public setMinValue(min: number): this {
numberValidator.parse(min);
Reflect.set(this, 'min_value', min);
return this;
}
/**
* {@inheritDoc ApplicationCommandOptionBase.toJSON}
*/
public toJSON(): APIApplicationCommandNumberOption {
this.runRequiredValidations();
if (this.autocomplete && Array.isArray(this.choices) && this.choices.length > 0) {
throw new RangeError('Autocomplete and choices are mutually exclusive to each other.');
}
return { ...this } as APIApplicationCommandNumberOption;
}
}
export interface SlashCommandNumberOption
extends ApplicationCommandNumericOptionMinMaxValueMixin,
ApplicationCommandOptionWithChoicesMixin<number>,
ApplicationCommandOptionWithAutocompleteMixin {}

View File

@@ -0,0 +1,21 @@
import { ApplicationCommandOptionType, type APIApplicationCommandRoleOption } from 'discord-api-types/v10';
import { ApplicationCommandOptionBase } from '../mixins/ApplicationCommandOptionBase.js';
/**
* A slash command role option.
*/
export class SlashCommandRoleOption extends ApplicationCommandOptionBase {
/**
* The type of this option.
*/
public override readonly type = ApplicationCommandOptionType.Role as const;
/**
* {@inheritDoc ApplicationCommandOptionBase.toJSON}
*/
public toJSON(): APIApplicationCommandRoleOption {
this.runRequiredValidations();
return { ...this };
}
}

Some files were not shown because too many files have changed in this diff Show More