Compare commits

..

224 Commits

Author SHA1 Message Date
Jiralite
c5b71a756b chore(ws): release @discordjs/ws@2.0.4 2025-11-10 13:16:13 +00:00
Denis-Adrian Cristea
6c781ede30 fix(WebSocketShard): bad error re-throw (#11151)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-11-10 13:04:07 +00:00
Denis-Adrian Cristea
be38128ea1 fix(SimpleIdentifyThrottler): don't sleep negative amounts (#10669)
* fix(SimpleIdentifyThrottler): don't sleep negative amounts

* fix: test
2025-11-10 12:59:18 +00:00
Denis-Adrian Cristea
737a97d068 refactor(IContextFetchingStrategy): explicitly name forwarded properties (#10652) 2025-11-10 12:58:28 +00:00
Danial Raza
b26af3cf38 feat: add linked roles formatters (#10461)
* feat: add linked roles formatters

* docs: requested changes

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

* docs: remove locale

---------

Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-11-09 11:02:08 +00:00
cobalt
169b05f319 refactor(formatters): Change :_: emoji name placeholder (#10567)
* Change `:_:` emoji name placeholder

* Update tests

* Format
2025-11-09 11:01:04 +00:00
Almeida
bf0430f998 feat: add email and phoneNumber formatters (#11050)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-11-09 10:56:24 +00:00
Josef
2da2fa01b2 feat: optimize role manager cache getter (#11239)
Co-authored-by: Almeida <github@almeidx.dev>
2025-11-09 10:52:23 +00:00
Kendell R
1c5701651a fix: improve handling of italics in the presence of links (#11064)
* fix(formatters): don't escape * in links

* add test for * -> * in url

* `\S+` -> `\S*` (tested locally)

* @Qjuh: handle italics both inside+outside <url>s

* more accurate <link> matcher

---------

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
2025-11-09 10:51:50 +00:00
Jiralite
b6a8264d6b chore: Merge builders/1.x into v14 (#11260)
* chore: merge builders (and formatters)

* chore: match cliff.toml

* chore: update README.mds

* build: discord-api-types 0.38.32
2025-11-08 23:31:07 +00:00
Naiyar
a7196dc969 fix: backport only passing relevant options to API when fetching (#11230)
* fix: only pass relevant options to API when fetching

* chore: update changes
2025-10-31 21:35:51 +00:00
Jiralite
a03661844f chore(discord.js): release discord.js@14.24.2 2025-10-30 21:20:01 +00:00
Jiralite
fb2b7281e0 fix(GuildMember): joinedAt possibly being NaN
Co-Authored-By: TÆMBØØ <69138346+TAEMBO@users.noreply.github.com>
2025-10-30 21:01:17 +00:00
Jiralite
c303bf3329 chore(discord.js): release discord.js@14.24.1 2025-10-28 15:06:27 +00:00
Danial Raza
c2c8cce1d7 fix(Message): check if in voice based channel for pinnable (#11215)
* fix(Message): check if in voice based channel for `pinnable`

* chore: changes from main

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-10-28 14:59:42 +00:00
Jiralite
abb84ce88f docs(GuildMemberFlagsBitField): remove duplicate word 2025-10-26 10:47:53 +00:00
Jiralite
d317ca1053 types(FileUploadModalData): Correct fields (#11209)
* types(FileUploadModalData): update fields

* docs(FileUploadModalData): `Snowflake`

* types(FileUploadModalData): `Snowflake`

* docs: add more
2025-10-25 23:57:39 +01:00
Jiralite
072fbb228a types(LabelModalData): Singular ModalData (#11207)
types(LabelModalData): no array
2025-10-25 20:13:26 +01:00
Jiralite
548c25488a types(FileUploadComponentData): boolean 2025-10-25 17:17:32 +01:00
Jiralite
16a44f83e5 chore(discord.js): release discord.js@14.24.0 2025-10-24 17:12:01 +01:00
Jiralite
0dda270ea5 build: bump @discordjs/builders 2025-10-24 16:22:20 +01:00
Danial Raza
ee988e3e75 fix(Message): update pinnable to check for migrated guilds (#11189)
* fix(Message): update `pinnable` to check for migrated guilds

* refactor: requested changes

* refactor: no checkAdmin and clean up the mess
2025-10-24 16:05:12 +01:00
Naiyar
104ad754f3 feat: handle file upload component for v14 (#11179)
* feat: handle file upload component

* chore: fix import

* chore: typings

* fix: `Snowflake`

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-10-24 13:19:37 +01:00
Jiralite
0ff239a602 build: bump discord-api-types to 0.38.31 2025-10-23 23:04:14 +01:00
Jiralite
89fd19e08a build: upgrade @discordjs/builders to 1.12.2 2025-10-15 23:16:36 +01:00
Jiralite
6a6c7d0333 build: upgrade discord-api-types to 0.38.30 2025-10-15 23:16:03 +01:00
Jiralite
083f6abb38 chore(discord.js): release discord.js@14.23.2 2025-10-09 02:10:39 +01:00
Jiralite
5cc13b735c fix(ModalSubmitInteraction): Better resolving of components (#11162)
* fix: fix value crash

* fix: use a set

* fix: `const`
2025-10-09 02:05:29 +01:00
Jiralite
1e4d1dc04f fix: handle DM modals 2025-10-08 23:02:34 +01:00
Jiralite
177d81f596 chore(discord.js): release discord.js@14.23.1 2025-10-08 22:13:52 +01:00
Jiralite
bf4cfeb4bf build: upgrade builders to 1.12.1 2025-10-08 22:08:03 +01:00
Jiralite
11b236ff65 fix(ModalSubmitInteraction): Resolve crash on handling populated select menus (#11158)
* fix: handle receiving new selects

* fix: handle missing user object
2025-10-08 22:07:48 +01:00
Almeida
1d5b9837de fix: ending uncached polls (#11157) 2025-10-08 22:04:23 +01:00
Jiralite
8065b80cea chore: update 11135 name 2025-10-08 18:15:00 +01:00
Vlad Frangu
3b26680672 chore(discord.js): release discord.js@14.23.0 2025-10-08 20:14:05 +03:00
Jiralite
c4dbd7ee9f chore(core): release @discordjs/core@2.3.0 2025-10-08 18:09:11 +01:00
Almeida
72771b79aa feat: add {add,remove}GroupDMRecipient methods (#11135)
* feat: add `{add,delete}GroupDMRecipient methods`

* fix: requested changes

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-08 16:03:50 +01:00
Jiralite
63dbe48055 feat(guild): Support incident actions (#11131)
* feat(guild): add incident actions

* fix: add result

---------

Co-Authored-By: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-08 16:02:03 +01:00
Vlad Frangu
67c8953a10 feat: bump builders in v14 (and fix runtime crashes) (#11153)
* feat: bump builders in v14 (and fix runtime crashes)

* chore: bump dtypes

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

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

* chore: requested changes

* chore: lint

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-10-08 14:47:39 +01:00
Pavel-Boyazov
30e35d909e types(ClientEventTypes): fix messageDeleteBulk event arg (#11122)
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-10-07 17:44:32 +01:00
Pavel-Boyazov
6a5707c786 types(Webhook): specify message type (#11142)
* types(Webhook): specify message type

* test(Webhook): update types
2025-10-07 17:40:05 +01:00
Jiralite
9b821e5dfc feat(GuildMemberManager): Add new modify self fields (#11112)
* feat(GuildMemberManager): Add new modify self fields (#11089)

* fix: use correct route

* fix: add deprecation

* fix: rewrite message
2025-10-06 08:36:28 +01:00
Jiralite
a04172325a feat: Add gateway endpoints (#11130)
feat: add gateway
Co-Authored-By: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-04 17:51:37 +01:00
Almeida
154c00ded9 fix(ThreadMemberFlagsBitField): use ThreadMemberFlags enum in Flags (#11118)
feat(ThreadMemberFlagsBitField): use `ThreadMemberFlags` enum in `Flags`
2025-10-02 21:59:27 +01:00
Almeida
3b927449ae docs: use LocalizationMap where applicable (#11117) 2025-10-02 21:58:28 +01:00
Naiyar
fcce0d95bb fix: backport in operator fix from main (#11127)
fix: use in operator when resolving modal component (#11115)
2025-10-01 15:54:03 +01:00
Naiyar
93e0f4cd10 feat: text display and more selects in modal for v14 (#11096)
* feat: handle recieve label components

* chore: missed fixes

* fix: missing id when transforming

* chore: add missing things

* fix: test

* feat: send label

* fix: un-break it

* chore: test

* feat: more selects in modals

* chore: make resolved read-only

* chore: import order

* chore: add missing cached generic

* style: spacing

* docs: consistency

* docs: make it a type

* docs: Add `APISelectMenuDefaultValue`

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-09-21 22:28:33 +01:00
Naiyar
abaae4ff16 feat: label component and select in modal for v14 (#11090)
* feat: handle recieve label components

* chore: missed fixes

* fix: missing id when transforming

* chore: add missing things

* fix: test

* feat: send label

* fix: un-break it

* chore: test

* chore: missing required in typings
2025-09-15 21:28:07 +01:00
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
373 changed files with 19393 additions and 6740 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

@@ -2,6 +2,52 @@
All notable changes to this project will be documented in this file.
# [@discordjs/builders@1.13.0](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.12.2...@discordjs/builders@1.13.0) - (2025-10-24)
## Features
- V1 builders file uploads support (#11196) ([1417c49](https://github.com/discordjs/discord.js/commit/1417c498a40b843d772ecf88dfff5f87a1665042))
## Testing
- Fix type error ([f780c6a](https://github.com/discordjs/discord.js/commit/f780c6a5500f7ea5c7a1ea7cd6720f6159d9d36e))
# [@discordjs/builders@1.12.2](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.12.1...@discordjs/builders@1.12.2) - (2025-10-09)
## Bug Fixes
- **Assertions:** Literal default values ([43362c9](https://github.com/discordjs/discord.js/commit/43362c93525f98d72b894eb0fc6b358d30ec45b9))
# [@discordjs/builders@1.12.1](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.12.0...@discordjs/builders@1.12.1) - (2025-10-08)
## Bug Fixes
- **builders:** Text display component support for modals (#11155) ([99b8436](https://github.com/discordjs/discord.js/commit/99b8436117bc12654278337abc4a23f5bdf4ba46))
# [@discordjs/builders@1.12.0](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.11.3...@discordjs/builders@1.12.0) - (2025-10-08)
## Features
- **builders:** Modal select menus in builders v1 (#11138) ([ac683b9](https://github.com/discordjs/discord.js/commit/ac683b9d040635de8514c80a9d433d9c6d63701b))
# [@discordjs/builders@1.11.3](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.11.2...@discordjs/builders@1.11.3) - (2025-08-10)
## Bug Fixes
- **contextMenuCommands:** Remove regular expression validation (#10996) ([4906aae](https://github.com/discordjs/discord.js/commit/4906aaea4c0e6e868fa658d3359026eb662fbcb8))
# [@discordjs/builders@1.11.0](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.10.1...@discordjs/builders@1.11.0) - (2025-04-25)
## Features
- Components v2 in builders v1 (#10787) ([118e682](https://github.com/discordjs/discord.js/commit/118e6826821b3b90f5923e40f167747e0658cfd1))
# [@discordjs/builders@1.10.1](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.10.0...@discordjs/builders@1.10.1) - (2025-02-10)
## Bug Fixes
- **EmbedBuilder:** Allow empty `name` and `value` on fields (#10747) ([49ef3a8](https://github.com/discordjs/discord.js/commit/49ef3a833eab23d426d5c667e28aa493ddc9cb6c))
# [@discordjs/builders@1.9.0](https://github.com/discordjs/discord.js/compare/@discordjs/builders@1.8.2...@discordjs/builders@1.9.0) - (2024-09-01)
## Features

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>
@@ -23,7 +24,7 @@
## Installation
**Node.js 18 or newer is required.**
**Node.js 16.11.0 or newer is required.**
```sh
npm install @discordjs/builders

View File

@@ -2,18 +2,18 @@ import {
ButtonStyle,
ComponentType,
type APIActionRowComponent,
type APIMessageActionRowComponent,
type APIComponentInMessageActionRow,
} from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import {
ActionRowBuilder,
ButtonBuilder,
createComponentBuilder,
PrimaryButtonBuilder,
StringSelectMenuBuilder,
StringSelectMenuOptionBuilder,
} from '../../src/index.js';
const rowWithButtonData: APIActionRowComponent<APIMessageActionRowComponent> = {
const rowWithButtonData: APIActionRowComponent<APIComponentInMessageActionRow> = {
type: ComponentType.ActionRow,
components: [
{
@@ -25,7 +25,7 @@ const rowWithButtonData: APIActionRowComponent<APIMessageActionRowComponent> = {
],
};
const rowWithSelectMenuData: APIActionRowComponent<APIMessageActionRowComponent> = {
const rowWithSelectMenuData: APIActionRowComponent<APIComponentInMessageActionRow> = {
type: ComponentType.ActionRow,
components: [
{
@@ -41,16 +41,23 @@ 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> = {
const actionRowData: APIActionRowComponent<APIComponentInMessageActionRow> = {
type: ComponentType.ActionRow,
components: [
{
@@ -65,15 +72,27 @@ 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();
});
test('GIVEN valid builder options THEN valid JSON output is given', () => {
const rowWithButtonData: APIActionRowComponent<APIMessageActionRowComponent> = {
const rowWithButtonData: APIActionRowComponent<APIComponentInMessageActionRow> = {
type: ComponentType.ActionRow,
components: [
{
@@ -85,7 +104,7 @@ describe('Action Row Components', () => {
],
};
const rowWithSelectMenuData: APIActionRowComponent<APIMessageActionRowComponent> = {
const rowWithSelectMenuData: APIActionRowComponent<APIComponentInMessageActionRow> = {
type: ComponentType.ActionRow,
components: [
{
@@ -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

@@ -3,7 +3,7 @@ import {
ComponentType,
TextInputStyle,
type APIButtonComponent,
type APIMessageActionRowComponent,
type APIComponentInMessageActionRow,
type APISelectMenuComponent,
type APITextInputComponent,
type APIActionRowComponent,
@@ -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();
@@ -27,7 +27,7 @@ describe('createComponentBuilder', () => {
);
test('GIVEN an action row component THEN returns a ActionRowBuilder', () => {
const actionRow: APIActionRowComponent<APIMessageActionRowComponent> = {
const actionRow: APIActionRowComponent<APIComponentInMessageActionRow> = {
components: [],
type: ComponentType.ActionRow,
};
@@ -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

@@ -0,0 +1,46 @@
import type { APIFileUploadComponent } from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { FileUploadBuilder } from '../../src/components/fileUpload/FileUpload.js';
describe('File Upload Components', () => {
test('Valid builder does not throw.', () => {
expect(() => new FileUploadBuilder().setCustomId('file_upload').toJSON()).not.toThrowError();
expect(() => new FileUploadBuilder().setCustomId('file_upload').setId(5).toJSON()).not.toThrowError();
expect(() =>
new FileUploadBuilder().setCustomId('file_upload').setMaxValues(5).setMinValues(2).toJSON(),
).not.toThrowError();
expect(() => new FileUploadBuilder().setCustomId('file_upload').setRequired(false).toJSON()).not.toThrowError();
});
test('Invalid builder does throw.', () => {
expect(() => new FileUploadBuilder().toJSON()).toThrowError();
expect(() => new FileUploadBuilder().setCustomId('file_upload').setId(-3).toJSON()).toThrowError();
expect(() => new FileUploadBuilder().setMaxValues(5).setMinValues(2).setId(10).toJSON()).toThrowError();
expect(() => new FileUploadBuilder().setCustomId('file_upload').setMaxValues(500).toJSON()).toThrowError();
expect(() =>
new FileUploadBuilder().setCustomId('file_upload').setMinValues(500).setMaxValues(501).toJSON(),
).toThrowError();
expect(() => new FileUploadBuilder().setRequired(false).toJSON()).toThrowError();
});
test('API data equals toJSON().', () => {
const fileUploadData = {
type: ComponentType.FileUpload,
custom_id: 'file_upload',
min_values: 4,
max_values: 9,
required: false,
} satisfies APIFileUploadComponent;
expect(new FileUploadBuilder(fileUploadData).toJSON()).toEqual(fileUploadData);
expect(
new FileUploadBuilder().setCustomId('file_upload').setMinValues(4).setMaxValues(9).setRequired(false).toJSON(),
).toEqual(fileUploadData);
});
});

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();
});
});
@@ -40,11 +100,11 @@ describe('Text Input Components', () => {
.setPlaceholder('hello')
.setStyle(TextInputStyle.Paragraph)
.toJSON();
}).toThrowError();
}).not.toThrowError();
});
test('GIVEN valid input THEN valid JSON outputs are given', () => {
const textInputData: APITextInputComponent = {
const textInputData = {
type: ComponentType.TextInput,
label: 'label',
custom_id: 'custom id',
@@ -54,7 +114,7 @@ describe('Text Input Components', () => {
value: 'value',
required: false,
style: TextInputStyle.Paragraph,
};
} satisfies APITextInputComponent;
expect(new TextInputBuilder(textInputData).toJSON()).toEqual(textInputData);
expect(

View File

@@ -0,0 +1,248 @@
import { type APIContainerComponent, ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { ActionRowBuilder } from '../../../src/components/ActionRow.js';
import { createComponentBuilder } from '../../../src/components/Components.js';
import { ButtonBuilder } from '../../../src/components/button/Button.js';
import { ContainerBuilder } from '../../../src/components/v2/Container.js';
import { FileBuilder } from '../../../src/components/v2/File.js';
import { MediaGalleryBuilder } from '../../../src/components/v2/MediaGallery.js';
import { SectionBuilder } from '../../../src/components/v2/Section.js';
import { SeparatorBuilder } from '../../../src/components/v2/Separator.js';
import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay.js';
const containerWithTextDisplay: APIContainerComponent = {
type: ComponentType.Container,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
id: 123,
},
],
};
const containerWithSeparatorData: APIContainerComponent = {
type: ComponentType.Container,
components: [
{
type: ComponentType.Separator,
id: 1_234,
spacing: SeparatorSpacingSize.Small,
divider: false,
},
],
accent_color: 0x00ff00,
};
const containerWithSeparatorDataNoColor: APIContainerComponent = {
type: ComponentType.Container,
components: [
{
type: ComponentType.Separator,
id: 1_234,
spacing: SeparatorSpacingSize.Small,
divider: false,
},
],
};
describe('Container Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid components THEN do not throw', () => {
expect(() =>
new ContainerBuilder().addActionRowComponents(
new ActionRowBuilder<ButtonBuilder>().addComponents(new ButtonBuilder()),
),
).not.toThrowError();
expect(() => new ContainerBuilder().addFileComponents(new FileBuilder())).not.toThrowError();
expect(() => new ContainerBuilder().addMediaGalleryComponents(new MediaGalleryBuilder())).not.toThrowError();
expect(() => new ContainerBuilder().addSectionComponents(new SectionBuilder())).not.toThrowError();
expect(() => new ContainerBuilder().addSeparatorComponents(new SeparatorBuilder())).not.toThrowError();
expect(() => new ContainerBuilder().addTextDisplayComponents(new TextDisplayBuilder())).not.toThrowError();
expect(() => new ContainerBuilder().spliceComponents(0, 0, new SeparatorBuilder())).not.toThrowError();
expect(() => new ContainerBuilder().addSeparatorComponents([new SeparatorBuilder()])).not.toThrowError();
expect(() =>
new ContainerBuilder().spliceComponents(0, 0, [{ type: ComponentType.Separator }]),
).not.toThrowError();
});
test('GIVEN valid JSON input THEN valid JSON output is given', () => {
const containerData: APIContainerComponent = {
type: ComponentType.Container,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
id: 3,
},
{
type: ComponentType.Separator,
spacing: SeparatorSpacingSize.Large,
divider: true,
id: 4,
},
{
type: ComponentType.File,
file: {
url: 'attachment://file.png',
},
spoiler: false,
},
],
accent_color: 0xff00ff,
spoiler: true,
};
expect(new ContainerBuilder(containerData).toJSON()).toEqual(containerData);
expect(() => createComponentBuilder({ type: ComponentType.Container, components: [] })).not.toThrowError();
});
test('GIVEN valid builder options THEN valid JSON output is given', () => {
const containerWithTextDisplay: APIContainerComponent = {
type: ComponentType.Container,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
id: 123,
},
],
};
const containerWithSeparatorData: APIContainerComponent = {
type: ComponentType.Container,
components: [
{
type: ComponentType.Separator,
id: 1_234,
spacing: SeparatorSpacingSize.Small,
divider: false,
},
],
accent_color: 0x00ff00,
};
expect(new ContainerBuilder(containerWithTextDisplay).toJSON()).toEqual(containerWithTextDisplay);
expect(new ContainerBuilder(containerWithSeparatorData).toJSON()).toEqual(containerWithSeparatorData);
expect(() => createComponentBuilder({ type: ComponentType.Container, components: [] })).not.toThrowError();
});
test('GIVEN valid builder options THEN valid JSON output is given 2', () => {
const textDisplay = new TextDisplayBuilder().setContent('test').setId(123);
const separator = new SeparatorBuilder().setId(1_234).setSpacing(SeparatorSpacingSize.Small).setDivider(false);
expect(new ContainerBuilder().addTextDisplayComponents(textDisplay).toJSON()).toEqual(containerWithTextDisplay);
expect(new ContainerBuilder().addSeparatorComponents(separator).toJSON()).toEqual(
containerWithSeparatorDataNoColor,
);
expect(new ContainerBuilder().addTextDisplayComponents([textDisplay]).toJSON()).toEqual(containerWithTextDisplay);
expect(new ContainerBuilder().addSeparatorComponents([separator]).toJSON()).toEqual(
containerWithSeparatorDataNoColor,
);
});
test('GIVEN valid accent color THEN valid JSON output is given', () => {
expect(
new ContainerBuilder({
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
})
.setAccentColor([255, 0, 255])
.toJSON(),
).toEqual({
type: ComponentType.Container,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
accent_color: 0xff00ff,
});
expect(
new ContainerBuilder({
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
})
.setAccentColor(0xff00ff)
.toJSON(),
).toEqual({
type: ComponentType.Container,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
accent_color: 0xff00ff,
});
expect(
new ContainerBuilder({
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
})
.setAccentColor([255, 0, 255])
.clearAccentColor()
.toJSON(),
).toEqual({
type: ComponentType.Container,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
});
expect(new ContainerBuilder(containerWithSeparatorData).clearAccentColor().toJSON()).toEqual(
containerWithSeparatorDataNoColor,
);
});
test('GIVEN valid method parameters THEN valid JSON is given', () => {
expect(
new ContainerBuilder()
.addTextDisplayComponents(new TextDisplayBuilder().setId(3).clearId().setContent('test'))
.setSpoiler()
.toJSON(),
).toEqual({
type: ComponentType.Container,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
spoiler: true,
});
expect(
new ContainerBuilder()
.addTextDisplayComponents({ type: ComponentType.TextDisplay, content: 'test' })
.setSpoiler(false)
.setId(5)
.toJSON(),
).toEqual({
type: ComponentType.Container,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
spoiler: false,
id: 5,
});
});
});
});

View File

@@ -0,0 +1,44 @@
import { ComponentType } from 'discord-api-types/v10';
import { describe, expect, test } from 'vitest';
import { FileBuilder } from '../../../src/components/v2/File';
const dummy = {
type: ComponentType.File as const,
file: { url: 'attachment://owo.png' },
};
describe('File', () => {
describe('File url', () => {
test('GIVEN a file with a pre-defined url THEN return valid toJSON data', () => {
const file = new FileBuilder({ file: { url: 'attachment://owo.png' } });
expect(file.toJSON()).toEqual({ ...dummy, file: { url: 'attachment://owo.png' } });
});
test('GIVEN a file using File#setURL THEN return valid toJSON data', () => {
const file = new FileBuilder();
file.setURL('attachment://uwu.png');
expect(file.toJSON()).toEqual({ ...dummy, file: { url: 'attachment://uwu.png' } });
});
test('GIVEN a file with an invalid url THEN throws error', () => {
const file = new FileBuilder();
expect(() => file.setURL('https://google.com')).toThrowError();
});
});
describe('File spoiler', () => {
test('GIVEN a file with a pre-defined spoiler status THEN return valid toJSON data', () => {
const file = new FileBuilder({ ...dummy, spoiler: true });
expect(file.toJSON()).toEqual({ ...dummy, spoiler: true });
});
test('GIVEN a file using File#setSpoiler THEN return valid toJSON data', () => {
const file = new FileBuilder({ ...dummy });
file.setSpoiler(false);
expect(file.toJSON()).toEqual({ ...dummy, spoiler: false });
});
});
});

View File

@@ -0,0 +1,150 @@
import { type APIMediaGalleryItem, type APIMediaGalleryComponent, ComponentType } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { createComponentBuilder } from '../../../src/components/Components.js';
import { MediaGalleryBuilder } from '../../../src/components/v2/MediaGallery.js';
import { MediaGalleryItemBuilder } from '../../../src/components/v2/MediaGalleryItem.js';
const galleryHttpsDisplay: APIMediaGalleryComponent = {
type: ComponentType.MediaGallery,
items: [
{
description: 'test',
spoiler: false,
media: { url: 'https://discord.com/logo.png' },
},
],
};
const galleryAttachmentData: APIMediaGalleryComponent = {
type: ComponentType.MediaGallery,
items: [
{
media: { url: 'attachment://file.png' },
},
],
id: 123,
};
describe('Media Gallery Components', () => {
describe('Assertion Tests', () => {
test('GIVEN an empty media gallery THEN throws error', () => {
const gallery = new MediaGalleryBuilder();
expect(() => gallery.toJSON()).toThrow();
});
test('GIVEN valid items THEN do not throw', () => {
expect(() => new MediaGalleryBuilder().addItems(new MediaGalleryItemBuilder())).not.toThrowError();
expect(() => new MediaGalleryBuilder().spliceItems(0, 0, new MediaGalleryItemBuilder())).not.toThrowError();
expect(() => new MediaGalleryBuilder().addItems([new MediaGalleryItemBuilder()])).not.toThrowError();
expect(() => new MediaGalleryBuilder().spliceItems(0, 0, [new MediaGalleryItemBuilder()])).not.toThrowError();
});
test('GIVEN valid JSON input THEN valid JSON output is given', () => {
const mediaGalleryData: APIMediaGalleryComponent = {
type: ComponentType.MediaGallery,
items: [
{
media: { url: 'attachment://file.png' },
description: 'test',
spoiler: false,
},
{
media: { url: 'https://discord.js.org/logo.jpg' },
spoiler: true,
},
],
id: 1_234,
};
expect(new MediaGalleryBuilder(mediaGalleryData).toJSON()).toEqual(mediaGalleryData);
expect(() => createComponentBuilder({ type: ComponentType.MediaGallery, items: [] })).not.toThrowError();
});
test('GIVEN valid builder options THEN valid JSON output is given', () => {
const galleryHttpsDisplay: APIMediaGalleryComponent = {
type: ComponentType.MediaGallery,
items: [
{
description: 'test',
spoiler: false,
media: { url: 'https://discord.com/logo.png' },
},
],
};
const galleryAttachmentData: APIMediaGalleryComponent = {
type: ComponentType.MediaGallery,
items: [
{
media: { url: 'attachment://file.png' },
},
],
id: 123,
};
expect(new MediaGalleryBuilder(galleryHttpsDisplay).toJSON()).toEqual(galleryHttpsDisplay);
expect(new MediaGalleryBuilder(galleryAttachmentData).toJSON()).toEqual(galleryAttachmentData);
expect(() => createComponentBuilder({ type: ComponentType.MediaGallery, items: [] })).not.toThrowError();
});
test('GIVEN valid builder options THEN valid JSON output is given 2', () => {
const item1 = new MediaGalleryItemBuilder()
.setDescription('test')
.setSpoiler(false)
.setURL('https://discord.com/logo.png');
const item2 = new MediaGalleryItemBuilder().setURL('attachment://file.png');
expect(new MediaGalleryBuilder().addItems(item1).toJSON()).toEqual(galleryHttpsDisplay);
expect(new MediaGalleryBuilder().addItems(item2).setId(123).toJSON()).toEqual(galleryAttachmentData);
expect(new MediaGalleryBuilder().addItems([item1]).toJSON()).toEqual(galleryHttpsDisplay);
expect(new MediaGalleryBuilder().addItems([item2]).setId(123).toJSON()).toEqual(galleryAttachmentData);
});
test('GIVEN valid JSON options THEN valid JSON output is given 2', () => {
const item1: APIMediaGalleryItem = {
description: 'test',
spoiler: false,
media: { url: 'https://discord.com/logo.png' },
};
const item2 = {
media: { url: 'attachment://file.png' },
};
expect(new MediaGalleryBuilder().addItems(item1).toJSON()).toEqual(galleryHttpsDisplay);
expect(new MediaGalleryBuilder().addItems(item2).setId(123).toJSON()).toEqual(galleryAttachmentData);
expect(new MediaGalleryBuilder().addItems([item1]).toJSON()).toEqual(galleryHttpsDisplay);
expect(new MediaGalleryBuilder().addItems([item2]).setId(123).toJSON()).toEqual(galleryAttachmentData);
});
test('GIVEN valid builder callback THEN valid JSON output is given', () => {
const item1 = new MediaGalleryItemBuilder()
.setDescription('test')
.setSpoiler(false)
.setURL('https://discord.com/logo.png');
const item2 = new MediaGalleryItemBuilder().setURL('attachment://file.png');
expect(
new MediaGalleryBuilder()
.addItems((item) => item.setDescription('test').setSpoiler(false).setURL('https://discord.com/logo.png'))
.toJSON(),
).toEqual(galleryHttpsDisplay);
expect(
new MediaGalleryBuilder()
.spliceItems(0, 0, (item) => item.setURL('attachment://file.png'))
.setId(123)
.toJSON(),
).toEqual(galleryAttachmentData);
expect(
new MediaGalleryBuilder()
.addItems([(item) => item.setDescription('test').setSpoiler(false).setURL('https://discord.com/logo.png')])
.toJSON(),
).toEqual(galleryHttpsDisplay);
expect(
new MediaGalleryBuilder()
.spliceItems(0, 0, [(item) => item.setDescription('test').clearDescription().setURL('attachment://file.png')])
.setId(123)
.toJSON(),
).toEqual(galleryAttachmentData);
});
});
});

View File

@@ -0,0 +1,191 @@
import { type APISectionComponent, ButtonStyle, ComponentType } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { createComponentBuilder } from '../../../src/components/Components.js';
import { ButtonBuilder } from '../../../src/components/button/Button.js';
import { SectionBuilder } from '../../../src/components/v2/Section.js';
import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay.js';
import { ThumbnailBuilder } from '../../../src/components/v2/Thumbnail.js';
const sectionWithButtonData: APISectionComponent = {
type: ComponentType.Section,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
accessory: {
type: ComponentType.Button,
label: 'test',
custom_id: '123',
style: ButtonStyle.Primary,
},
};
const sectionWithThumbnailData: APISectionComponent = {
type: ComponentType.Section,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
accessory: {
type: ComponentType.Thumbnail,
media: { url: 'attachment://file.png' },
spoiler: true,
description: 'test',
},
};
describe('Section Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid components THEN do not throw', () => {
expect(() => new SectionBuilder().addTextDisplayComponents(new TextDisplayBuilder())).not.toThrowError();
expect(() => new SectionBuilder().spliceTextDisplayComponents(0, 0, new TextDisplayBuilder())).not.toThrowError();
expect(() => new SectionBuilder().addTextDisplayComponents([new TextDisplayBuilder()])).not.toThrowError();
expect(() =>
new SectionBuilder().spliceTextDisplayComponents(0, 0, [new TextDisplayBuilder()]),
).not.toThrowError();
});
test('GIVEN valid JSON input THEN valid JSON output is given', () => {
const sectionData: APISectionComponent = {
type: ComponentType.Section,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
id: 123,
},
{
type: ComponentType.TextDisplay,
content: 'test',
},
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
accessory: {
type: ComponentType.Thumbnail,
media: { url: 'attachment://file.png' },
},
};
expect(new SectionBuilder(sectionData).toJSON()).toEqual(sectionData);
expect(() =>
createComponentBuilder({
type: ComponentType.Section,
components: [],
accessory: { type: ComponentType.Thumbnail, media: { url: 'https://discord.com/logo.png' } },
}),
).not.toThrowError();
});
test('GIVEN valid builder options THEN valid JSON output is given', () => {
const sectionWithButtonData: APISectionComponent = {
type: ComponentType.Section,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
accessory: {
type: ComponentType.Button,
label: 'test',
custom_id: '123',
style: ButtonStyle.Primary,
},
};
const sectionWithThumbnailData: APISectionComponent = {
type: ComponentType.Section,
components: [
{
type: ComponentType.TextDisplay,
content: 'test',
},
],
accessory: {
type: ComponentType.Thumbnail,
media: { url: 'attachment://file.png' },
spoiler: true,
description: 'test',
},
};
expect(new SectionBuilder(sectionWithButtonData).toJSON()).toEqual(sectionWithButtonData);
expect(new SectionBuilder(sectionWithThumbnailData).toJSON()).toEqual(sectionWithThumbnailData);
expect(() =>
createComponentBuilder({
type: ComponentType.Section,
components: [],
accessory: {
type: ComponentType.Button,
label: 'test',
custom_id: '123',
style: ButtonStyle.Primary,
},
}),
).not.toThrowError();
});
test('GIVEN valid builder options THEN valid JSON output is given 2', () => {
const button = new ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123');
const thumbnail = new ThumbnailBuilder().setDescription('test').setSpoiler().setURL('attachment://file.png');
const textDisplay = new TextDisplayBuilder().setContent('test');
expect(new SectionBuilder().addTextDisplayComponents(textDisplay).setButtonAccessory(button).toJSON()).toEqual(
sectionWithButtonData,
);
expect(
new SectionBuilder().addTextDisplayComponents(textDisplay).setThumbnailAccessory(thumbnail).toJSON(),
).toEqual(sectionWithThumbnailData);
expect(
new SectionBuilder()
.addTextDisplayComponents([textDisplay])
.setButtonAccessory((button) => button.setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123'))
.toJSON(),
).toEqual(sectionWithButtonData);
expect(
new SectionBuilder()
.addTextDisplayComponents([textDisplay])
.setThumbnailAccessory((thumbnail) =>
thumbnail.setDescription('test').setSpoiler().setURL('attachment://file.png'),
)
.toJSON(),
).toEqual(sectionWithThumbnailData);
});
test('GIVEN valid builder callback THEN valid JSON output is given', () => {
const button = new ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123');
expect(
new SectionBuilder()
.addTextDisplayComponents((textDisplay) => textDisplay.setContent('test'))
.setButtonAccessory(button)
.toJSON(),
).toEqual(sectionWithButtonData);
expect(
new SectionBuilder()
.spliceTextDisplayComponents(0, 0, (textDisplay) => textDisplay.setContent('test'))
.setButtonAccessory(button)
.toJSON(),
).toEqual(sectionWithButtonData);
expect(
new SectionBuilder()
.addTextDisplayComponents([(textDisplay) => textDisplay.setContent('test')])
.setButtonAccessory(button)
.toJSON(),
).toEqual(sectionWithButtonData);
expect(
new SectionBuilder()
.spliceTextDisplayComponents(0, 0, [(textDisplay) => textDisplay.setContent('test')])
.setButtonAccessory(button)
.toJSON(),
).toEqual(sectionWithButtonData);
});
});
});

View File

@@ -0,0 +1,35 @@
import { ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10';
import { describe, expect, test } from 'vitest';
import { SeparatorBuilder } from '../../../src/components/v2/Separator';
describe('Separator', () => {
describe('Divider', () => {
test('GIVEN a separator with a pre-defined divider THEN return valid toJSON data', () => {
const separator = new SeparatorBuilder({ divider: true });
expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, divider: true });
});
test('GIVEN a separator with a set divider THEN return valid toJSON data', () => {
const separator = new SeparatorBuilder().setDivider(false);
expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, divider: false });
});
});
describe('Spacing', () => {
test('GIVEN a separator with a pre-defined spacing THEN return valid toJSON data', () => {
const separator = new SeparatorBuilder({ spacing: SeparatorSpacingSize.Small });
expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, spacing: SeparatorSpacingSize.Small });
});
test('GIVEN a separator with a set spacing THEN return valid toJSON data', () => {
const separator = new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large);
expect(separator.toJSON()).toEqual({ type: ComponentType.Separator, spacing: SeparatorSpacingSize.Large });
});
test('GIVEN a separator with a set spacing THEN clear spacing THEN return valid toJSON data', () => {
const separator = new SeparatorBuilder({ spacing: SeparatorSpacingSize.Small });
separator.clearSpacing();
expect(separator.toJSON()).toEqual({ type: ComponentType.Separator });
});
});
});

View File

@@ -0,0 +1,23 @@
import { ComponentType } from 'discord-api-types/v10';
import { describe, expect, test } from 'vitest';
import { TextDisplayBuilder } from '../../../src/components/v2/TextDisplay';
describe('TextDisplay', () => {
describe('TextDisplay content', () => {
test('GIVEN a text display with a pre-defined content THEN return valid toJSON data', () => {
const textDisplay = new TextDisplayBuilder({ content: 'foo' });
expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'foo' });
});
test('GIVEN a text display with a set content THEN return valid toJSON data', () => {
const textDisplay = new TextDisplayBuilder().setContent('foo');
expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'foo' });
});
test('GIVEN a text display with a pre-defined content THEN overwritten content THEN return valid toJSON data', () => {
const textDisplay = new TextDisplayBuilder({ content: 'foo' });
textDisplay.setContent('bar');
expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'bar' });
});
});
});

View File

@@ -0,0 +1,69 @@
import { ComponentType } from 'discord-api-types/v10';
import { describe, expect, test } from 'vitest';
import { ThumbnailBuilder } from '../../../src/components/v2/Thumbnail';
const dummy = {
type: ComponentType.Thumbnail as const,
media: { url: 'https://google.com' },
};
describe('Thumbnail', () => {
describe('Thumbnail url', () => {
test('GIVEN a thumbnail with a pre-defined url THEN return valid toJSON data', () => {
const thumbnail = new ThumbnailBuilder({ media: { url: 'https://google.com' } });
expect(thumbnail.toJSON()).toEqual({ type: ComponentType.Thumbnail, media: { url: 'https://google.com' } });
});
test('GIVEN a thumbnail with a set url THEN return valid toJSON data', () => {
const thumbnail = new ThumbnailBuilder().setURL('https://google.com');
expect(thumbnail.toJSON()).toEqual({ type: ComponentType.Thumbnail, media: { url: 'https://google.com' } });
});
test.each(['owo', 'discord://user'])('GIVEN a thumbnail with an invalid URL (%s) THEN throws error', (input) => {
const thumbnail = new ThumbnailBuilder();
expect(() => thumbnail.setURL(input)).toThrowError();
});
});
describe('Thumbnail description', () => {
test('GIVEN a thumbnail with a pre-defined description THEN return valid toJSON data', () => {
const thumbnail = new ThumbnailBuilder({ ...dummy, description: 'foo' });
expect(thumbnail.toJSON()).toEqual({ ...dummy, description: 'foo' });
});
test('GIVEN a thumbnail with a set description THEN return valid toJSON data', () => {
const thumbnail = new ThumbnailBuilder({ ...dummy });
thumbnail.setDescription('foo');
expect(thumbnail.toJSON()).toEqual({ ...dummy, description: 'foo' });
});
test('GIVEN a thumbnail with a pre-defined description THEN unset description THEN return valid toJSON data', () => {
const thumbnail = new ThumbnailBuilder({ description: 'foo', ...dummy });
thumbnail.clearDescription();
expect(thumbnail.toJSON()).toEqual({ ...dummy });
});
test('GIVEN a thumbnail with an invalid description THEN throws error', () => {
const thumbnail = new ThumbnailBuilder();
expect(() => thumbnail.setDescription('a'.repeat(1_025))).toThrowError();
});
});
describe('Thumbnail spoiler', () => {
test('GIVEN a thumbnail with a pre-defined spoiler status THEN return valid toJSON data', () => {
const thumbnail = new ThumbnailBuilder({ ...dummy, spoiler: true });
expect(thumbnail.toJSON()).toEqual({ ...dummy, spoiler: true });
});
test('GIVEN a thumbnail with a set spoiler status THEN return valid toJSON data', () => {
const thumbnail = new ThumbnailBuilder({ ...dummy });
thumbnail.setSpoiler(false);
expect(thumbnail.toJSON()).toEqual({ ...dummy, spoiler: false });
});
});
});

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,72 @@
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();
// This should be fine, even with trailing and leading spaces (API trims it).
expect(() => ContextMenuCommandAssertions.validateName(' 🩵 ABC 123 $%^& ')).not.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();
});
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 +74,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 +104,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 +132,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,10 +156,10 @@ 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();
});
});
@@ -126,10 +182,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 integraton 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,19 +316,24 @@ 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', () => {
const embed = new EmbedBuilder();
embed.addFields({ name: 'foo', value: 'bar' });
embed.addFields([{ name: 'foo', value: 'bar' }]);
embed.addFields([
{ name: 'foo', value: 'bar' },
{ name: '', value: '' },
]);
expect(embed.toJSON()).toStrictEqual({
...base,
fields: [
{ name: 'foo', value: 'bar' },
{ name: 'foo', value: 'bar' },
{ name: '', value: '' },
],
});
});
@@ -356,79 +342,67 @@ 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();
test('GIVEN invalid field amount THEN throws error', () => {
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();
});
describe('GIVEN invalid field name THEN throws error', () => {
test('2', () => {
const embed = new EmbedBuilder();
test('GIVEN invalid field name length THEN throws error', () => {
const embed = new EmbedBuilder();
embed.addFields({ name: '', value: 'bar' });
expect(() => embed.toJSON()).toThrowError();
});
expect(() => embed.addFields({ name: 'a'.repeat(257), value: 'bar' })).toThrowError();
});
describe('GIVEN invalid field name length THEN throws error', () => {
test('3', () => {
const embed = new EmbedBuilder();
test('GIVEN invalid field value length THEN throws error', () => {
const embed = new EmbedBuilder();
embed.addFields({ name: 'a'.repeat(257), value: 'bar' });
expect(() => embed.toJSON()).toThrowError();
});
});
describe('GIVEN invalid field value length THEN throws error', () => {
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

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@discordjs/builders",
"version": "1.9.0",
"version": "1.13.0",
"description": "A set of builders that you can use when creating your bot",
"scripts": {
"test": "vitest run",
@@ -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.38.32",
"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",
@@ -90,7 +91,7 @@
"vitest": "^2.0.5"
},
"engines": {
"node": ">=18"
"node": ">=16.11.0"
},
"publishConfig": {
"access": "public",

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,61 @@
/* 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 APIComponentInMessageActionRow,
type APIComponentInModalActionRow,
type APIComponentInActionRow,
} 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 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<APIComponentInMessageActionRow | APIComponentInModalActionRow>
> {
/**
* 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 +91,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<APIComponentInActionRow>> = {}) {
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,134 @@
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 idValidator = s
.number()
.safeInt()
.greaterThanOrEqual(1)
.lessThan(4_294_967_296) // 2^32 - 1
.setValidationEnabled(isValidationEnabled);
export const emojiPredicate = z
export const customIdValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(100)
.setValidationEnabled(isValidationEnabled);
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,23 +1,70 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIActionRowComponent, APIActionRowComponentTypes } from 'discord-api-types/v10';
import type {
APIActionRowComponent,
APIComponentInActionRow,
APIBaseComponent,
ComponentType,
APIMessageComponent,
APIModalComponent,
} from 'discord-api-types/v10';
import { idValidator } from './Assertions';
/**
* Any action row component data represented as an object.
*/
export type AnyAPIActionRowComponent = APIActionRowComponent<APIActionRowComponentTypes> | APIActionRowComponentTypes;
export type AnyAPIActionRowComponent =
| APIActionRowComponent<APIComponentInActionRow>
| APIComponentInActionRow
| APIMessageComponent
| APIModalComponent;
/**
* 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;
}
/**
* Sets the id (not the custom id) for this component.
*
* @param id - The id for this component
*/
public setId(id: number) {
this.data.id = idValidator.parse(id);
return this;
}
/**
* Clears the id of this component, defaulting to a default incremented id.
*/
public clearId() {
this.data.id = undefined;
return this;
}
}

View File

@@ -1,65 +1,42 @@
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 type { JSONEncodable } from '@discordjs/util';
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 MessageActionRowComponentBuilder,
type AnyComponentBuilder,
type ModalComponentBuilder,
} from './ActionRow.js';
import { ComponentBuilder } from './Component.js';
import { ButtonBuilder } from './button/Button.js';
import { FileUploadBuilder } from './fileUpload/FileUpload.js';
import { LabelBuilder } from './label/Label.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';
import { ContainerBuilder } from './v2/Container.js';
import { FileBuilder } from './v2/File.js';
import { MediaGalleryBuilder } from './v2/MediaGallery.js';
import { SectionBuilder } from './v2/Section.js';
import { SeparatorBuilder } from './v2/Separator.js';
import { TextDisplayBuilder } from './v2/TextDisplay.js';
import { ThumbnailBuilder } from './v2/Thumbnail.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;
export type MessageComponentBuilder =
| ActionRowBuilder<MessageActionRowComponentBuilder>
| ContainerBuilder
| FileBuilder
| MediaGalleryBuilder
| MessageActionRowComponentBuilder
| SectionBuilder
| SeparatorBuilder
| TextDisplayBuilder
| ThumbnailBuilder;
/**
* Components here are mapped to their respective builder.
@@ -68,9 +45,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;
/**
@@ -97,6 +74,42 @@ export interface MappedComponentTypes {
* The channel select component type is associated with a {@link ChannelSelectMenuBuilder}.
*/
[ComponentType.ChannelSelect]: ChannelSelectMenuBuilder;
/**
* The file component type is associated with a {@link FileBuilder}.
*/
[ComponentType.File]: FileBuilder;
/**
* The separator component type is associated with a {@link SeparatorBuilder}.
*/
[ComponentType.Separator]: SeparatorBuilder;
/**
* The container component type is associated with a {@link ContainerBuilder}.
*/
[ComponentType.Container]: ContainerBuilder;
/**
* The text display component type is associated with a {@link TextDisplayBuilder}.
*/
[ComponentType.TextDisplay]: TextDisplayBuilder;
/**
* The thumbnail component type is associated with a {@link ThumbnailBuilder}.
*/
[ComponentType.Thumbnail]: ThumbnailBuilder;
/**
* The section component type is associated with a {@link SectionBuilder}.
*/
[ComponentType.Section]: SectionBuilder;
/**
* The media gallery component type is associated with a {@link MediaGalleryBuilder}.
*/
[ComponentType.MediaGallery]: MediaGalleryBuilder;
/**
* The label component type is associated with a {@link LabelBuilder}.
*/
[ComponentType.Label]: LabelBuilder;
/**
* The file upload component type is associated with a {@link FileUploadBuilder}.
*/
[ComponentType.FileUpload]: FileUploadBuilder;
}
/**
@@ -122,7 +135,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 +144,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:
@@ -144,28 +157,48 @@ export function createComponentBuilder(
return new MentionableSelectMenuBuilder(data);
case ComponentType.ChannelSelect:
return new ChannelSelectMenuBuilder(data);
case ComponentType.File:
return new FileBuilder(data);
case ComponentType.Container:
return new ContainerBuilder(data);
case ComponentType.Section:
return new SectionBuilder(data);
case ComponentType.Separator:
return new SeparatorBuilder(data);
case ComponentType.TextDisplay:
return new TextDisplayBuilder(data);
case ComponentType.Thumbnail:
return new ThumbnailBuilder(data);
case ComponentType.MediaGallery:
return new MediaGalleryBuilder(data);
case ComponentType.Label:
return new LabelBuilder(data);
case ComponentType.FileUpload:
return new FileUploadBuilder(data);
default:
// @ts-expect-error This case can still occur if we get a newer unsupported component type
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}`);
}
function isBuilder<Builder extends JSONEncodable<any>>(
builder: unknown,
Constructor: new () => Builder,
): builder is Builder {
return builder instanceof Constructor;
}
export function resolveBuilder<ComponentType extends Record<PropertyKey, any>, Builder extends JSONEncodable<any>>(
builder: Builder | ComponentType | ((builder: Builder) => Builder),
Constructor: new (data?: ComponentType) => Builder,
) {
if (isBuilder(builder, Constructor)) {
return builder;
}
if (typeof builder === 'function') {
return builder(new Constructor());
}
return new Constructor(builder);
}

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

@@ -0,0 +1,12 @@
import { s } from '@sapphire/shapeshift';
import { ComponentType } from 'discord-api-types/v10';
import { customIdValidator, idValidator } from '../Assertions.js';
export const fileUploadPredicate = s.object({
type: s.literal(ComponentType.FileUpload),
id: idValidator.optional(),
custom_id: customIdValidator,
min_values: s.number().greaterThanOrEqual(0).lessThanOrEqual(10).optional(),
max_values: s.number().greaterThanOrEqual(1).lessThanOrEqual(10).optional(),
required: s.boolean().optional(),
});

View File

@@ -0,0 +1,99 @@
import { type APIFileUploadComponent, ComponentType } from 'discord-api-types/v10';
import { ComponentBuilder } from '../Component.js';
import { fileUploadPredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for file uploads.
*/
export class FileUploadBuilder extends ComponentBuilder<APIFileUploadComponent> {
/**
* Creates a new file upload.
*
* @param data - The API data to create this file upload with
* @example
* Creating a file upload from an API data object:
* ```ts
* const fileUpload = new FileUploadBuilder({
* custom_id: "file_upload",
* min_values: 2,
* max_values: 5,
* });
* ```
* @example
* Creating a file upload using setters and API data:
* ```ts
* const fileUpload = new FileUploadBuilder({
* custom_id: "file_upload",
* min_values: 2,
* max_values: 5,
* }).setRequired();
* ```
*/
public constructor(data: Partial<APIFileUploadComponent> = {}) {
super({ type: ComponentType.FileUpload, ...data });
}
/**
* Sets the custom id for this file upload.
*
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customId;
return this;
}
/**
* Sets the minimum number of file uploads required.
*
* @param minValues - The minimum values that must be uploaded
*/
public setMinValues(minValues: number) {
this.data.min_values = minValues;
return this;
}
/**
* Clears the minimum values.
*/
public clearMinValues() {
this.data.min_values = undefined;
return this;
}
/**
* Sets the maximum number of file uploads required.
*
* @param maxValues - The maximum values that can be uploaded
*/
public setMaxValues(maxValues: number) {
this.data.max_values = maxValues;
return this;
}
/**
* Clears the maximum values.
*/
public clearMaxValues() {
this.data.max_values = undefined;
return this;
}
/**
* Sets whether this file upload is required.
*
* @param required - Whether this file upload is required
*/
public setRequired(required = true) {
this.data.required = required;
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): APIFileUploadComponent {
fileUploadPredicate.parse(this.data);
return this.data as APIFileUploadComponent;
}
}

View File

@@ -0,0 +1,31 @@
import { s } from '@sapphire/shapeshift';
import { ComponentType } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { idValidator } from '../Assertions.js';
import { fileUploadPredicate } from '../fileUpload/Assertions.js';
import {
selectMenuChannelPredicate,
selectMenuMentionablePredicate,
selectMenuRolePredicate,
selectMenuStringPredicate,
selectMenuUserPredicate,
} from '../selectMenu/Assertions.js';
import { textInputPredicate } from '../textInput/Assertions.js';
export const labelPredicate = s
.object({
id: idValidator.optional(),
type: s.literal(ComponentType.Label),
label: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(45),
description: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100).optional(),
component: s.union([
textInputPredicate,
selectMenuUserPredicate,
selectMenuRolePredicate,
selectMenuMentionablePredicate,
selectMenuChannelPredicate,
selectMenuStringPredicate,
fileUploadPredicate,
]),
})
.setValidationEnabled(isValidationEnabled);

View File

@@ -0,0 +1,213 @@
import type {
APIChannelSelectComponent,
APIFileUploadComponent,
APILabelComponent,
APIMentionableSelectComponent,
APIRoleSelectComponent,
APIStringSelectComponent,
APITextInputComponent,
APIUserSelectComponent,
} from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { ComponentBuilder } from '../Component.js';
import { createComponentBuilder, resolveBuilder } from '../Components.js';
import { FileUploadBuilder } from '../fileUpload/FileUpload.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';
import { labelPredicate } from './Assertions.js';
export interface LabelBuilderData extends Partial<Omit<APILabelComponent, 'component'>> {
component?:
| ChannelSelectMenuBuilder
| FileUploadBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| TextInputBuilder
| UserSelectMenuBuilder;
}
/**
* A builder that creates API-compatible JSON data for labels.
*/
export class LabelBuilder extends ComponentBuilder<LabelBuilderData> {
/**
* @internal
*/
public override readonly data: LabelBuilderData;
/**
* Creates a new label.
*
* @param data - The API data to create this label with
* @example
* Creating a label from an API data object:
* ```ts
* const label = new LabelBuilder({
* label: "label",
* component,
* });
* ```
* @example
* Creating a label using setters and API data:
* ```ts
* const label = new LabelBuilder({
* label: 'label',
* component,
* }).setLabel('new text');
* ```
*/
public constructor(data: Partial<APILabelComponent> = {}) {
super({ type: ComponentType.Label });
const { component, ...rest } = data;
this.data = {
...rest,
component: component ? createComponentBuilder(component) : undefined,
type: ComponentType.Label,
};
}
/**
* Sets the label for this label.
*
* @param label - The label to use
*/
public setLabel(label: string) {
this.data.label = label;
return this;
}
/**
* Sets the description for this label.
*
* @param description - The description to use
*/
public setDescription(description: string) {
this.data.description = description;
return this;
}
/**
* Clears the description for this label.
*/
public clearDescription() {
this.data.description = undefined;
return this;
}
/**
* Sets a string select menu component to this label.
*
* @param input - A function that returns a component builder or an already built builder
*/
public setStringSelectMenuComponent(
input:
| APIStringSelectComponent
| StringSelectMenuBuilder
| ((builder: StringSelectMenuBuilder) => StringSelectMenuBuilder),
): this {
this.data.component = resolveBuilder(input, StringSelectMenuBuilder);
return this;
}
/**
* Sets a user select menu component to this label.
*
* @param input - A function that returns a component builder or an already built builder
*/
public setUserSelectMenuComponent(
input: APIUserSelectComponent | UserSelectMenuBuilder | ((builder: UserSelectMenuBuilder) => UserSelectMenuBuilder),
): this {
this.data.component = resolveBuilder(input, UserSelectMenuBuilder);
return this;
}
/**
* Sets a role select menu component to this label.
*
* @param input - A function that returns a component builder or an already built builder
*/
public setRoleSelectMenuComponent(
input: APIRoleSelectComponent | RoleSelectMenuBuilder | ((builder: RoleSelectMenuBuilder) => RoleSelectMenuBuilder),
): this {
this.data.component = resolveBuilder(input, RoleSelectMenuBuilder);
return this;
}
/**
* Sets a mentionable select menu component to this label.
*
* @param input - A function that returns a component builder or an already built builder
*/
public setMentionableSelectMenuComponent(
input:
| APIMentionableSelectComponent
| MentionableSelectMenuBuilder
| ((builder: MentionableSelectMenuBuilder) => MentionableSelectMenuBuilder),
): this {
this.data.component = resolveBuilder(input, MentionableSelectMenuBuilder);
return this;
}
/**
* Sets a channel select menu component to this label.
*
* @param input - A function that returns a component builder or an already built builder
*/
public setChannelSelectMenuComponent(
input:
| APIChannelSelectComponent
| ChannelSelectMenuBuilder
| ((builder: ChannelSelectMenuBuilder) => ChannelSelectMenuBuilder),
): this {
this.data.component = resolveBuilder(input, ChannelSelectMenuBuilder);
return this;
}
/**
* Sets a text input component to this label.
*
* @param input - A function that returns a component builder or an already built builder
*/
public setTextInputComponent(
input: APITextInputComponent | TextInputBuilder | ((builder: TextInputBuilder) => TextInputBuilder),
): this {
this.data.component = resolveBuilder(input, TextInputBuilder);
return this;
}
/**
* Sets a file upload component to this label.
*
* @param input - A function that returns a component builder or an already built builder
*/
public setFileUploadComponent(
input: APIFileUploadComponent | FileUploadBuilder | ((builder: FileUploadBuilder) => FileUploadBuilder),
): this {
this.data.component = resolveBuilder(input, FileUploadBuilder);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(): APILabelComponent {
const { component, ...rest } = this.data;
const data = {
...rest,
// The label predicate validates the component.
component: component?.toJSON(),
};
labelPredicate.parse(data);
return data as APILabelComponent;
}
}

View File

@@ -0,0 +1,92 @@
import { Result, s } from '@sapphire/shapeshift';
import { ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { customIdValidator, emojiValidator, idValidator } from '../Assertions.js';
import { labelValidator } from '../textInput/Assertions.js';
const selectMenuBasePredicate = s.object({
id: idValidator.optional(),
placeholder: s.string().lengthLessThanOrEqual(150).optional(),
min_values: s.number().greaterThanOrEqual(0).lessThanOrEqual(25).optional(),
max_values: s.number().greaterThanOrEqual(0).lessThanOrEqual(25).optional(),
custom_id: customIdValidator,
disabled: s.boolean().optional(),
});
export const selectMenuChannelPredicate = selectMenuBasePredicate
.extend({
type: s.literal(ComponentType.ChannelSelect),
channel_types: s.nativeEnum(ChannelType).array().optional(),
default_values: s
.object({ id: s.string(), type: s.literal(SelectMenuDefaultValueType.Channel) })
.array()
.lengthLessThanOrEqual(25)
.optional(),
})
.setValidationEnabled(isValidationEnabled);
export const selectMenuMentionablePredicate = selectMenuBasePredicate
.extend({
type: s.literal(ComponentType.MentionableSelect),
default_values: s
.object({
id: s.string(),
type: s.union([s.literal(SelectMenuDefaultValueType.Role), s.literal(SelectMenuDefaultValueType.User)]),
})
.array()
.lengthLessThanOrEqual(25)
.optional(),
})
.setValidationEnabled(isValidationEnabled);
export const selectMenuRolePredicate = selectMenuBasePredicate
.extend({
type: s.literal(ComponentType.RoleSelect),
default_values: s
.object({ id: s.string(), type: s.literal(SelectMenuDefaultValueType.Role) })
.array()
.lengthLessThanOrEqual(25)
.optional(),
})
.setValidationEnabled(isValidationEnabled);
export const selectMenuUserPredicate = selectMenuBasePredicate
.extend({
type: s.literal(ComponentType.UserSelect),
default_values: s
.object({ id: s.string(), type: s.literal(SelectMenuDefaultValueType.User) })
.array()
.lengthLessThanOrEqual(25)
.optional(),
})
.setValidationEnabled(isValidationEnabled);
export const selectMenuStringOptionPredicate = s
.object({
label: labelValidator,
value: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100),
description: s.string().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(100).optional(),
emoji: emojiValidator.optional(),
default: s.boolean().optional(),
})
.setValidationEnabled(isValidationEnabled);
export const selectMenuStringPredicate = selectMenuBasePredicate
.extend({
type: s.literal(ComponentType.StringSelect),
options: selectMenuStringOptionPredicate.array().lengthGreaterThanOrEqual(1).lengthLessThanOrEqual(25),
})
.reshape((value) => {
if (value.min_values !== undefined && value.options.length < value.min_values) {
return Result.err(new RangeError(`The number of options must be greater than or equal to min_values`));
}
if (value.min_values !== undefined && value.max_values !== undefined && value.min_values > value.max_values) {
return Result.err(
new RangeError(`The maximum amount of options must be greater than or equal to the minimum amount of options`),
);
}
return Result.ok(value);
})
.setValidationEnabled(isValidationEnabled);

View File

@@ -1,35 +1,23 @@
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';
import { requiredValidator } from '../textInput/Assertions.js';
/**
* The base select menu builder that contains common symbols for select menu builders.
*
* @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,17 +27,17 @@ 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;
}
/**
* Sets the maximum values that must be selected in the select menu.
* Sets the maximum values that can be selected in the select menu.
*
* @param maxValues - The maximum values that must be selected
* @param maxValues - The maximum values that can be selected
*/
public setMaxValues(maxValues: number) {
this.data.max_values = maxValues;
this.data.max_values = minMaxValidator.parse(maxValues);
return this;
}
@@ -59,7 +47,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 +57,28 @@ 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;
}
/**
* Sets whether this select menu is required.
*
* @remarks Only for use in modals.
* @param required - Whether this select menu is required
*/
public setRequired(required = true) {
this.data.required = requiredValidator.parse(required);
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);
}
/**
@@ -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,45 @@
import { s } from '@sapphire/shapeshift';
import { ComponentType, TextInputStyle } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate } from '../../Assertions.js';
import { isValidationEnabled } from '../../util/validation.js';
import { customIdValidator, idValidator } 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).setValidationEnabled(isValidationEnabled);
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().setValidationEnabled(isValidationEnabled);
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 const textInputPredicate = s
.object({
type: s.literal(ComponentType.TextInput),
custom_id: customIdValidator,
style: textInputStyleValidator,
id: idValidator.optional(),
min_length: minLengthValidator.optional(),
max_length: maxLengthValidator.optional(),
placeholder: placeholderValidator.optional(),
value: valueValidator.optional(),
required: requiredValidator.optional(),
})
.setValidationEnabled(isValidationEnabled);
export function validateRequiredParameters(customId?: string, style?: TextInputStyle) {
customIdValidator.parse(customId);
textInputStyleValidator.parse(style);
}

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.
*
@@ -18,7 +30,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* ```ts
* const textInput = new TextInputBuilder({
* custom_id: 'a cool text input',
* label: 'Type something',
* placeholder: 'Type something',
* style: TextInputStyle.Short,
* });
* ```
@@ -26,15 +38,14 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* Creating a text input using setters and API data:
* ```ts
* const textInput = new TextInputBuilder({
* label: 'Type something else',
* placeholder: 'Type something else',
* })
* .setCustomId('woah')
* .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;
}
@@ -51,9 +62,10 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* Sets the label for this text input.
*
* @param label - The label to use
* @deprecated Use a label builder to create a label (and optionally a description) instead.
*/
public setLabel(label: string) {
this.data.label = label;
this.data.label = labelValidator.parse(label);
return this;
}
@@ -63,7 +75,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 +85,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 +95,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 +105,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 +115,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 +125,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);
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

@@ -0,0 +1,72 @@
import { s } from '@sapphire/shapeshift';
import { SeparatorSpacingSize } from 'discord-api-types/v10';
import { colorPredicate } from '../../messages/embed/Assertions';
import { isValidationEnabled } from '../../util/validation';
import { ComponentBuilder } from '../Component';
import { ButtonBuilder } from '../button/Button';
import type { ContainerComponentBuilder } from './Container';
import type { MediaGalleryItemBuilder } from './MediaGalleryItem';
import type { TextDisplayBuilder } from './TextDisplay';
import { ThumbnailBuilder } from './Thumbnail';
export const unfurledMediaItemPredicate = s
.object({
url: s
.string()
.url(
{ allowedProtocols: ['http:', 'https:', 'attachment:'] },
{ message: 'Invalid protocol for media URL. Must be http:, https:, or attachment:' },
),
})
.setValidationEnabled(isValidationEnabled);
export const descriptionPredicate = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(1_024)
.setValidationEnabled(isValidationEnabled);
export const filePredicate = s
.object({
url: s
.string()
.url({ allowedProtocols: ['attachment:'] }, { message: 'Invalid protocol for file URL. Must be attachment:' }),
})
.setValidationEnabled(isValidationEnabled);
export const spoilerPredicate = s.boolean();
export const dividerPredicate = s.boolean();
export const spacingPredicate = s.nativeEnum(SeparatorSpacingSize);
export const textDisplayContentPredicate = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(4_000)
.setValidationEnabled(isValidationEnabled);
export const accessoryPredicate = s
.instance(ButtonBuilder)
.or(s.instance(ThumbnailBuilder))
.setValidationEnabled(isValidationEnabled);
export const containerColorPredicate = colorPredicate.nullish();
export function assertReturnOfBuilder<ReturnType extends MediaGalleryItemBuilder | TextDisplayBuilder>(
input: unknown,
ExpectedInstanceOf: new () => ReturnType,
): asserts input is ReturnType {
s.instance(ExpectedInstanceOf).setValidationEnabled(isValidationEnabled).parse(input);
}
export function validateComponentArray<
ReturnType extends ContainerComponentBuilder | MediaGalleryItemBuilder = ContainerComponentBuilder,
>(input: unknown, min: number, max: number, ExpectedInstanceOf?: new () => ReturnType): asserts input is ReturnType[] {
(ExpectedInstanceOf ? s.instance(ExpectedInstanceOf) : s.instance(ComponentBuilder))
.array()
.lengthGreaterThanOrEqual(min)
.lengthLessThanOrEqual(max)
.setValidationEnabled(isValidationEnabled)
.parse(input);
}

View File

@@ -0,0 +1,239 @@
/* eslint-disable jsdoc/check-param-names */
import type {
APIActionRowComponent,
APIComponentInContainer,
APIComponentInMessageActionRow,
APIContainerComponent,
APIFileComponent,
APIMediaGalleryComponent,
APISectionComponent,
APISeparatorComponent,
APITextDisplayComponent,
} from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import type { RGBTuple } from '../../index.js';
import { MediaGalleryBuilder, SectionBuilder } from '../../index.js';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import type { AnyComponentBuilder, MessageActionRowComponentBuilder } from '../ActionRow.js';
import { ActionRowBuilder } from '../ActionRow.js';
import { ComponentBuilder } from '../Component.js';
import { createComponentBuilder, resolveBuilder } from '../Components.js';
import { containerColorPredicate, spoilerPredicate } from './Assertions.js';
import { FileBuilder } from './File.js';
import { SeparatorBuilder } from './Separator.js';
import { TextDisplayBuilder } from './TextDisplay.js';
/**
* The builders that may be used within a container.
*/
export type ContainerComponentBuilder =
| ActionRowBuilder<AnyComponentBuilder>
| FileBuilder
| MediaGalleryBuilder
| SectionBuilder
| SeparatorBuilder
| TextDisplayBuilder;
/**
* A builder that creates API-compatible JSON data for a container.
*/
export class ContainerBuilder extends ComponentBuilder<APIContainerComponent> {
/**
* The components within this container.
*/
public readonly components: ContainerComponentBuilder[];
/**
* Creates a new container from API data.
*
* @param data - The API data to create this container with
* @example
* Creating a container from an API data object:
* ```ts
* const container = new ContainerBuilder({
* components: [
* {
* content: "Some text here",
* type: ComponentType.TextDisplay,
* },
* ],
* });
* ```
* @example
* Creating a container using setters and API data:
* ```ts
* const container = new ContainerBuilder({
* components: [
* {
* content: "# Heading",
* type: ComponentType.TextDisplay,
* },
* ],
* })
* .addComponents(separator, section);
* ```
*/
public constructor({ components, ...data }: Partial<APIContainerComponent> = {}) {
super({ type: ComponentType.Container, ...data });
this.components = (components?.map((component) => createComponentBuilder(component)) ??
[]) as ContainerComponentBuilder[];
}
/**
* Sets the accent color of this container.
*
* @param color - The color to use
*/
public setAccentColor(color?: RGBTuple | number): this {
// Data assertions
containerColorPredicate.parse(color);
if (Array.isArray(color)) {
const [red, green, blue] = color;
this.data.accent_color = (red << 16) + (green << 8) + blue;
return this;
}
this.data.accent_color = color;
return this;
}
/**
* Clears the accent color of this container.
*/
public clearAccentColor() {
this.data.accent_color = undefined;
return this;
}
/**
* Adds action row components to this container.
*
* @param components - The action row components to add
*/
public addActionRowComponents<ComponentType extends MessageActionRowComponentBuilder>(
...components: RestOrArray<
| ActionRowBuilder<ComponentType>
| APIActionRowComponent<APIComponentInMessageActionRow>
| ((builder: ActionRowBuilder<ComponentType>) => ActionRowBuilder<ComponentType>)
>
) {
this.components.push(
...normalizeArray(components).map((component) => resolveBuilder(component, ActionRowBuilder<ComponentType>)),
);
return this;
}
/**
* Adds file components to this container.
*
* @param components - The file components to add
*/
public addFileComponents(
...components: RestOrArray<APIFileComponent | FileBuilder | ((builder: FileBuilder) => FileBuilder)>
) {
this.components.push(...normalizeArray(components).map((component) => resolveBuilder(component, FileBuilder)));
return this;
}
/**
* Adds media gallery components to this container.
*
* @param components - The media gallery components to add
*/
public addMediaGalleryComponents(
...components: RestOrArray<
APIMediaGalleryComponent | MediaGalleryBuilder | ((builder: MediaGalleryBuilder) => MediaGalleryBuilder)
>
) {
this.components.push(
...normalizeArray(components).map((component) => resolveBuilder(component, MediaGalleryBuilder)),
);
return this;
}
/**
* Adds section components to this container.
*
* @param components - The section components to add
*/
public addSectionComponents(
...components: RestOrArray<APISectionComponent | SectionBuilder | ((builder: SectionBuilder) => SectionBuilder)>
) {
this.components.push(...normalizeArray(components).map((component) => resolveBuilder(component, SectionBuilder)));
return this;
}
/**
* Adds separator components to this container.
*
* @param components - The separator components to add
*/
public addSeparatorComponents(
...components: RestOrArray<
APISeparatorComponent | SeparatorBuilder | ((builder: SeparatorBuilder) => SeparatorBuilder)
>
) {
this.components.push(...normalizeArray(components).map((component) => resolveBuilder(component, SeparatorBuilder)));
return this;
}
/**
* Adds text display components to this container.
*
* @param components - The text display components to add
*/
public addTextDisplayComponents(
...components: RestOrArray<
APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder)
>
) {
this.components.push(
...normalizeArray(components).map((component) => resolveBuilder(component, TextDisplayBuilder)),
);
return this;
}
/**
* Removes, replaces, or inserts components for this container.
*
* @param index - The index to start removing, replacing or inserting components
* @param deleteCount - The amount of components to remove
* @param components - The components to set
*/
public spliceComponents(
index: number,
deleteCount: number,
...components: RestOrArray<APIComponentInContainer | ContainerComponentBuilder>
) {
this.components.splice(
index,
deleteCount,
...normalizeArray(components).map((component) =>
component instanceof ComponentBuilder ? component : createComponentBuilder(component),
),
);
return this;
}
/**
* Sets the spoiler status of this container.
*
* @param spoiler - The spoiler status to use
*/
public setSpoiler(spoiler = true) {
this.data.spoiler = spoilerPredicate.parse(spoiler);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): APIContainerComponent {
return {
...this.data,
components: this.components.map((component) => component.toJSON()),
} as APIContainerComponent;
}
}

View File

@@ -0,0 +1,63 @@
import { ComponentType, type APIFileComponent } from 'discord-api-types/v10';
import { ComponentBuilder } from '../Component';
import { filePredicate, spoilerPredicate } from './Assertions';
export class FileBuilder extends ComponentBuilder<APIFileComponent> {
/**
* Creates a new file from API data.
*
* @param data - The API data to create this file with
* @example
* Creating a file from an API data object:
* ```ts
* const file = new FileBuilder({
* spoiler: true,
* file: {
* url: 'attachment://file.png',
* },
* });
* ```
* @example
* Creating a file using setters and API data:
* ```ts
* const file = new FileBuilder({
* file: {
* url: 'attachment://image.jpg',
* },
* })
* .setSpoiler(false);
* ```
*/
public constructor(data: Partial<APIFileComponent> = {}) {
super({ type: ComponentType.File, ...data, file: data.file ? { url: data.file.url } : undefined });
}
/**
* Sets the spoiler status of this file.
*
* @param spoiler - The spoiler status to use
*/
public setSpoiler(spoiler = true) {
this.data.spoiler = spoilerPredicate.parse(spoiler);
return this;
}
/**
* Sets the media URL of this file.
*
* @param url - The URL to use
*/
public setURL(url: string) {
this.data.file = filePredicate.parse({ url });
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(): APIFileComponent {
filePredicate.parse(this.data.file);
return { ...this.data, file: { ...this.data.file } } as APIFileComponent;
}
}

View File

@@ -0,0 +1,117 @@
/* eslint-disable jsdoc/check-param-names */
import type { APIMediaGalleryComponent, APIMediaGalleryItem } from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import { ComponentBuilder } from '../Component.js';
import { resolveBuilder } from '../Components.js';
import { assertReturnOfBuilder, validateComponentArray } from './Assertions.js';
import { MediaGalleryItemBuilder } from './MediaGalleryItem.js';
/**
* A builder that creates API-compatible JSON data for a container.
*/
export class MediaGalleryBuilder extends ComponentBuilder<APIMediaGalleryComponent> {
/**
* The components within this container.
*/
public readonly items: MediaGalleryItemBuilder[];
/**
* Creates a new media gallery from API data.
*
* @param data - The API data to create this media gallery with
* @example
* Creating a media gallery from an API data object:
* ```ts
* const mediaGallery = new MediaGalleryBuilder({
* items: [
* {
* description: "Some text here",
* media: {
* url: 'https://cdn.discordapp.com/embed/avatars/2.png',
* },
* },
* ],
* });
* ```
* @example
* Creating a media gallery using setters and API data:
* ```ts
* const mediaGallery = new MediaGalleryBuilder({
* items: [
* {
* description: "alt text",
* media: {
* url: 'https://cdn.discordapp.com/embed/avatars/5.png',
* },
* },
* ],
* })
* .addItems(item2, item3);
* ```
*/
public constructor({ items, ...data }: Partial<APIMediaGalleryComponent> = {}) {
super({ type: ComponentType.MediaGallery, ...data });
this.items = items?.map((item) => new MediaGalleryItemBuilder(item)) ?? [];
}
/**
* Adds items to this media gallery.
*
* @param items - The items to add
*/
public addItems(
...items: RestOrArray<
APIMediaGalleryItem | MediaGalleryItemBuilder | ((builder: MediaGalleryItemBuilder) => MediaGalleryItemBuilder)
>
) {
this.items.push(
...normalizeArray(items).map((input) => {
const result = resolveBuilder(input, MediaGalleryItemBuilder);
assertReturnOfBuilder(result, MediaGalleryItemBuilder);
return result;
}),
);
return this;
}
/**
* Removes, replaces, or inserts media gallery items for this media gallery.
*
* @param index - The index to start removing, replacing or inserting items
* @param deleteCount - The amount of items to remove
* @param items - The items to insert
*/
public spliceItems(
index: number,
deleteCount: number,
...items: RestOrArray<
APIMediaGalleryItem | MediaGalleryItemBuilder | ((builder: MediaGalleryItemBuilder) => MediaGalleryItemBuilder)
>
) {
this.items.splice(
index,
deleteCount,
...normalizeArray(items).map((input) => {
const result = resolveBuilder(input, MediaGalleryItemBuilder);
assertReturnOfBuilder(result, MediaGalleryItemBuilder);
return result;
}),
);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): APIMediaGalleryComponent {
validateComponentArray(this.items, 1, 10, MediaGalleryItemBuilder);
return {
...this.data,
items: this.items.map((item) => item.toJSON()),
} as APIMediaGalleryComponent;
}
}

View File

@@ -0,0 +1,90 @@
import type { JSONEncodable } from '@discordjs/util';
import type { APIMediaGalleryItem } from 'discord-api-types/v10';
import { descriptionPredicate, spoilerPredicate, unfurledMediaItemPredicate } from './Assertions';
export class MediaGalleryItemBuilder implements JSONEncodable<APIMediaGalleryItem> {
/**
* The API data associated with this media gallery item.
*/
public readonly data: Partial<APIMediaGalleryItem>;
/**
* Creates a new media gallery item from API data.
*
* @param data - The API data to create this media gallery item with
* @example
* Creating a media gallery item from an API data object:
* ```ts
* const item = new MediaGalleryItemBuilder({
* description: "Some text here",
* media: {
* url: 'https://cdn.discordapp.com/embed/avatars/2.png',
* },
* });
* ```
* @example
* Creating a media gallery item using setters and API data:
* ```ts
* const item = new MediaGalleryItemBuilder({
* media: {
* url: 'https://cdn.discordapp.com/embed/avatars/5.png',
* },
* })
* .setDescription("alt text");
* ```
*/
public constructor(data: Partial<APIMediaGalleryItem> = {}) {
this.data = data;
}
/**
* Sets the description of this media gallery item.
*
* @param description - The description to use
*/
public setDescription(description: string) {
this.data.description = descriptionPredicate.parse(description);
return this;
}
/**
* Clears the description of this media gallery item.
*/
public clearDescription() {
this.data.description = undefined;
return this;
}
/**
* Sets the spoiler status of this media gallery item.
*
* @param spoiler - The spoiler status to use
*/
public setSpoiler(spoiler = true) {
this.data.spoiler = spoilerPredicate.parse(spoiler);
return this;
}
/**
* Sets the media URL of this media gallery item.
*
* @param url - The URL to use
*/
public setURL(url: string) {
this.data.media = unfurledMediaItemPredicate.parse({ url });
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(): APIMediaGalleryItem {
unfurledMediaItemPredicate.parse(this.data.media);
return { ...this.data } as APIMediaGalleryItem;
}
}

View File

@@ -0,0 +1,153 @@
/* eslint-disable jsdoc/check-param-names */
import type {
APIButtonComponent,
APISectionComponent,
APITextDisplayComponent,
APIThumbnailComponent,
} from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { ButtonBuilder, ThumbnailBuilder } from '../../index.js';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import { ComponentBuilder } from '../Component.js';
import { createComponentBuilder, resolveBuilder } from '../Components.js';
import { accessoryPredicate, assertReturnOfBuilder, validateComponentArray } from './Assertions.js';
import { TextDisplayBuilder } from './TextDisplay.js';
/**
* A builder that creates API-compatible JSON data for a section.
*/
export class SectionBuilder extends ComponentBuilder<APISectionComponent> {
/**
* The components within this section.
*/
public readonly components: ComponentBuilder[];
/**
* The accessory of this section.
*/
public readonly accessory?: ButtonBuilder | ThumbnailBuilder;
/**
* Creates a new section from API data.
*
* @param data - The API data to create this section with
* @example
* Creating a section from an API data object:
* ```ts
* const section = new SectionBuilder({
* components: [
* {
* content: "Some text here",
* type: ComponentType.TextDisplay,
* },
* ],
* accessory: {
* media: {
* url: 'https://cdn.discordapp.com/embed/avatars/3.png',
* },
* }
* });
* ```
* @example
* Creating a section using setters and API data:
* ```ts
* const section = new SectionBuilder({
* components: [
* {
* content: "# Heading",
* type: ComponentType.TextDisplay,
* },
* ],
* })
* .setPrimaryButtonAccessory(button);
* ```
*/
public constructor({ components, accessory, ...data }: Partial<APISectionComponent> = {}) {
super({ type: ComponentType.Section, ...data });
this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ComponentBuilder[];
this.accessory = accessory ? createComponentBuilder(accessory) : undefined;
}
/**
* Sets the accessory of this section to a button.
*
* @param accessory - The accessory to use
*/
public setButtonAccessory(
accessory: APIButtonComponent | ButtonBuilder | ((builder: ButtonBuilder) => ButtonBuilder),
): this {
Reflect.set(this, 'accessory', accessoryPredicate.parse(resolveBuilder(accessory, ButtonBuilder)));
return this;
}
/**
* Sets the accessory of this section to a thumbnail.
*
* @param accessory - The accessory to use
*/
public setThumbnailAccessory(
accessory: APIThumbnailComponent | ThumbnailBuilder | ((builder: ThumbnailBuilder) => ThumbnailBuilder),
): this {
Reflect.set(this, 'accessory', accessoryPredicate.parse(resolveBuilder(accessory, ThumbnailBuilder)));
return this;
}
/**
* Adds text display components to this section.
*
* @param components - The text display components to add
*/
public addTextDisplayComponents(
...components: RestOrArray<TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder)>
) {
this.components.push(
...normalizeArray(components).map((input) => {
const result = resolveBuilder(input, TextDisplayBuilder);
assertReturnOfBuilder(result, TextDisplayBuilder);
return result;
}),
);
return this;
}
/**
* Removes, replaces, or inserts text display components for this section.
*
* @param index - The index to start removing, replacing or inserting text display components
* @param deleteCount - The amount of text display components to remove
* @param components - The text display components to insert
*/
public spliceTextDisplayComponents(
index: number,
deleteCount: number,
...components: RestOrArray<
APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder)
>
) {
this.components.splice(
index,
deleteCount,
...normalizeArray(components).map((input) => {
const result = resolveBuilder(input, TextDisplayBuilder);
assertReturnOfBuilder(result, TextDisplayBuilder);
return result;
}),
);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): APISectionComponent {
validateComponentArray(this.components, 1, 3, TextDisplayBuilder);
return {
...this.data,
components: this.components.map((component) => component.toJSON()),
accessory: accessoryPredicate.parse(this.accessory).toJSON(),
} as APISectionComponent;
}
}

View File

@@ -0,0 +1,69 @@
import type { SeparatorSpacingSize, APISeparatorComponent } from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { ComponentBuilder } from '../Component';
import { dividerPredicate, spacingPredicate } from './Assertions';
export class SeparatorBuilder extends ComponentBuilder<APISeparatorComponent> {
/**
* Creates a new separator from API data.
*
* @param data - The API data to create this separator with
* @example
* Creating a separator from an API data object:
* ```ts
* const separator = new SeparatorBuilder({
* spacing: SeparatorSpacingSize.Small,
* divider: true,
* });
* ```
* @example
* Creating a separator using setters and API data:
* ```ts
* const separator = new SeparatorBuilder({
* spacing: SeparatorSpacingSize.Large,
* })
* .setDivider(false);
* ```
*/
public constructor(data: Partial<APISeparatorComponent> = {}) {
super({
type: ComponentType.Separator,
...data,
});
}
/**
* Sets whether this separator should show a divider line.
*
* @param divider - Whether to show a divider line
*/
public setDivider(divider = true) {
this.data.divider = dividerPredicate.parse(divider);
return this;
}
/**
* Sets the spacing of this separator.
*
* @param spacing - The spacing to use
*/
public setSpacing(spacing: SeparatorSpacingSize) {
this.data.spacing = spacingPredicate.parse(spacing);
return this;
}
/**
* Clears the spacing of this separator.
*/
public clearSpacing() {
this.data.spacing = undefined;
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(): APISeparatorComponent {
return { ...this.data } as APISeparatorComponent;
}
}

View File

@@ -0,0 +1,52 @@
import type { APITextDisplayComponent } from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { ComponentBuilder } from '../Component';
import { textDisplayContentPredicate } from './Assertions';
export class TextDisplayBuilder extends ComponentBuilder<APITextDisplayComponent> {
/**
* Creates a new text display from API data.
*
* @param data - The API data to create this text display with
* @example
* Creating a text display from an API data object:
* ```ts
* const textDisplay = new TextDisplayBuilder({
* content: 'some text',
* });
* ```
* @example
* Creating a text display using setters and API data:
* ```ts
* const textDisplay = new TextDisplayBuilder({
* content: 'old text',
* })
* .setContent('new text');
* ```
*/
public constructor(data: Partial<APITextDisplayComponent> = {}) {
super({
type: ComponentType.TextDisplay,
...data,
});
}
/**
* Sets the text of this text display.
*
* @param content - The text to use
*/
public setContent(content: string) {
this.data.content = textDisplayContentPredicate.parse(content);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(): APITextDisplayComponent {
textDisplayContentPredicate.parse(this.data.content);
return { ...this.data } as APITextDisplayComponent;
}
}

View File

@@ -0,0 +1,86 @@
import type { APIThumbnailComponent } from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { ComponentBuilder } from '../Component';
import { descriptionPredicate, spoilerPredicate, unfurledMediaItemPredicate } from './Assertions';
export class ThumbnailBuilder extends ComponentBuilder<APIThumbnailComponent> {
/**
* Creates a new thumbnail from API data.
*
* @param data - The API data to create this thumbnail with
* @example
* Creating a thumbnail from an API data object:
* ```ts
* const thumbnail = new ThumbnailBuilder({
* description: 'some text',
* media: {
* url: 'https://cdn.discordapp.com/embed/avatars/4.png',
* },
* });
* ```
* @example
* Creating a thumbnail using setters and API data:
* ```ts
* const thumbnail = new ThumbnailBuilder({
* media: {
* url: 'attachment://image.png',
* },
* })
* .setDescription('alt text');
* ```
*/
public constructor(data: Partial<APIThumbnailComponent> = {}) {
super({
type: ComponentType.Thumbnail,
...data,
media: data.media ? { url: data.media.url } : undefined,
});
}
/**
* Sets the description of this thumbnail.
*
* @param description - The description to use
*/
public setDescription(description: string) {
this.data.description = descriptionPredicate.parse(description);
return this;
}
/**
* Clears the description of this thumbnail.
*/
public clearDescription() {
this.data.description = undefined;
return this;
}
/**
* Sets the spoiler status of this thumbnail.
*
* @param spoiler - The spoiler status to use
*/
public setSpoiler(spoiler = true) {
this.data.spoiler = spoilerPredicate.parse(spoiler);
return this;
}
/**
* Sets the media URL of this thumbnail.
*
* @param url - The URL to use
*/
public setURL(url: string) {
this.data.media = unfurledMediaItemPredicate.parse({ url });
return this;
}
/**
* {@inheritdoc ComponentBuilder.toJSON}
*/
public override toJSON(): APIThumbnailComponent {
unfurledMediaItemPredicate.parse(this.data.media);
return { ...this.data } as APIThumbnailComponent;
}
}

View File

@@ -1,71 +1,84 @@
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 * from './components/fileUpload/FileUpload.js';
export * as FileUploadAssertions from './components/fileUpload/Assertions.js';
export * from './components/ActionRow.js';
export * from './components/Assertions.js';
export * from './components/Component.js';
export * from './components/Components.js';
export * from './components/label/Label.js';
export * as LabelAssertions from './components/label/Assertions.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 * as ComponentsV2Assertions from './components/v2/Assertions.js';
export * from './components/v2/Container.js';
export * from './components/v2/File.js';
export * from './components/v2/MediaGallery.js';
export * from './components/v2/MediaGalleryItem.js';
export * from './components/v2/Section.js';
export * from './components/v2/Separator.js';
export * from './components/v2/TextDisplay.js';
export * from './components/v2/Thumbnail.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 * 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 './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;
}
}

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