Compare commits

..

50 Commits

Author SHA1 Message Date
Jiralite
28d5c84ddc style: Run Prettier (#10604)
style: prettier
2024-11-17 22:31:18 +00:00
Jiralite
c8977f29bd docs: Use info markdown (#10603) 2024-11-17 22:22:26 +00:00
Vlad Frangu
7397dfe49e chore(voice): release @discordjs/voice@0.18.0 (#10602)
* chore(voice): release @discordjs/voice@0.18.0

* chore: aes-256 note in readme
2024-11-17 22:17:45 +00:00
pat
9f8b9b1d66 feat(voice)!: add new encryption methods, remove old methods (#10451)
BREAKING CHANGE: This library no longer supports using `tweetnacl` as an encryption library due to Discord deprecating the algorithms that `tweetnacl` helped us support (read more [here](https://discord.com/developers/docs/change-log#voice-encryption-modes)). Please migrate to one of: `sodium-native`, `sodium`, `@stablelib/xchacha20poly1305`, `@noble/ciphers` or `libsodium-wrappers` unless your system supports `aes-256-gcm` (verify by running `require('node:crypto').getCiphers().includes('aes-256-gcm')`).

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
2024-11-18 00:08:51 +02:00
Jiralite
51a017a14e test: Fix builder methods in type test (#10599)
* test: fix builder methods in type test

* test: remove unused import
2024-11-17 12:18:46 +00:00
Qjuh
c45d912c98 refactor(GuildAuditLogsEntry)!: add type guard for narrowing (#10521)
BREAKING CHANGE: removed `GuildAuditLogsEntry.Targets.All` which wasn’t used anywhere

---------

Co-authored-by: Almeida <github@almeidx.dev>
2024-11-15 14:28:06 +00:00
Jiralite
3669d5e112 docs(channel): Clarify emoji parameter (#10595)
* docs(channel): clarify emoji parameter

* docs: actually add `@example`

* docs: clarify the kind of encoding

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>
2024-11-15 10:04:31 +00:00
Jiralite
6775175459 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-14 21:00:04 +00:00
Jiralite
e2df0e0dbc docs: Remove Node.js 10 notice (#10593)
docs: remove Node.js 10 notice
2024-11-12 06:50:35 +00:00
Naiyar
b8f5a68297 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:48:52 +00:00
cobalt
f2f7f1f65b refactor(formatters): Change :_: emoji name placeholder (#10567)
* Change `:_:` emoji name placeholder

* Update tests

* Format
2024-11-11 00:42:04 +00:00
René
c97310681d types(collection): simplify ambient constructor declaration (#10549)
- deduplicates constructor definition
- removes Collection's "internal" JSDoc description block
- removes unnecessary `extends` clause

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-11-07 11:12:53 +00:00
René
ea042458a3 perf(collection): optimisations (#10552)
* perf: `merge()`: deduplicate boolean checks

* perf: `toSorted()`: remove redundant closure

* perf: `last[Key]()`: order of operations

- do not perform iterable-to-array until required
- test ! before <

* perf: `{at,keyAt}()`: manually iterate to target

* perf: `first[Key]()`: avoid `Array.from()`

* perf: `map()`: avoid `Array.from()`

* perf: `random[Key]()`: avoid `Array.from()`

* test: `.{at,keyAt}()` indices

* perf: `last[Key]()`: use `.at()`/`.keyAt()` for single element

* perf: `first[Key]()`: use iterable-to-array if returning all

* perf: `random[Key]()`: use `{at,keyAt}()` for single value

- skip iterable-to-array for returning single value
- short-circuit if amount or collection size is zero

* perf: `random[Key]()`: use Durstenfeld shuffle

* refactor: `{key,keyAt}()`: reorder index check

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-11-07 11:03:26 +00:00
Jiralite
c34a57b798 fix(ThreadChannel): Address parameter type on fetchOwner() (#10579)
* fix(ThreadChannel): address parameter on owner helper method

* docs: fix description

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-05 22:19:48 +00:00
Naiyar
1184b38d3e refactor(ThreadManager)!: match parent ID when fetching a single thread (#10557)
BREAKING CHANGE: `ThreadManager#fetch` now throws when the provided thread ID doesn't belong to the current channel
2024-11-05 13:00:44 +02:00
Danial Raza
939e3644e1 types: add missing Caches managers (#10540)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-11-05 09:36:41 +00:00
Souji
f02bdc3be3 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:29 +00:00
Danial Raza
1fd662629d feat: add subscriptions (#10486)
* feat: add subscriptions

* docs: requested changes

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-04 10:48:41 +00:00
Jiralite
ef2a6879d3 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-04 10:43:34 +00:00
Danial Raza
a9f629b0d3 feat: add soundboard (#10536)
* feat: add soundboard

* chore: disable `jsdoc/check-param-names` rule

* fix: export `SoundboardSoundsAPI`
2024-11-04 10:03:13 +00:00
Pablo
ed78e45706 build: bump discord-api-types version (#10575)
* chore: bump discord-api-types version

* fix: delete extra file
2024-10-27 06:15:28 +00:00
Jiralite
b932b64d94 refactor: remove extra traversing (#10580)
* refactor: remove extra traversion

* refactor(GuildScheduledEventManager): address fetch
2024-10-25 09:39:47 +00:00
Jiralite
48a9c665de refactor(InteractionResponses)!: Remove ephemeral response option (#10564)
BREAKING CHANGE: MessagePayload#isInteraction no longer serves a purpose and has been removed.
BREAKING CHANGE: InteractionDeferReplyOptions no longer accepts ephemeral. Use flags instead.
BREAKING CHANGE: InteractionReplyOptions no longer accepts ephemeral. Use flags instead.
2024-10-22 09:10:30 +03:00
Qjuh
6cbe2487bc fix: missing tsdocConfig in api.json preventing index generation (#10565) 2024-10-19 21:34:51 +02:00
Qjuh
3540c3176c 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>
2024-10-19 00:04:01 +00:00
Qjuh
93b84ae7a6 refactor!: fix several issues with /ws incorporation (#10556)
BREAKING CHANGE: `Client#ping` is nullable now
2024-10-19 00:53:56 +01:00
Vlad Frangu
a9c92efba1 chore: make semver:major block kodiak (#10548)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-10-18 23:44:44 +00:00
Qjuh
62fb9de9c9 docs: allow @mixes TSDoc tag for documenting mixins (#10545) 2024-10-16 02:31:04 +02:00
almeidx
960a80dbae docs(Client): fix incorrect managers descriptions
Co-authored-by: Luna <84203950+Wolvinny@users.noreply.github.com>
2024-10-12 01:11:56 +03:00
almeidx
b16d851770 revert: docs: fix incorrect managers descriptions (#10519)
This reverts commit eded459335.
2024-10-12 01:11:56 +03:00
Luna
eded459335 docs(Client): fix incorrect managers descriptions (#10519)
* Edit manager descriptions

Some managers had incorrect descriptions, which applied only to the cache of the manager

* Update Client.js

* remove trailing space
2024-10-11 20:54:55 +00:00
Naiyar
79423c80b4 refactor!: exclude removed events from their enum (#10547)
BREAKING CHANGE: Removed the following members from `Events` enum: `Raw`, `ShardResume`, `ShardError`, `ShardReady`, `ShardReconnecting`, `ShardResume`, `ShardDisconnect`

BREAKING CHANGE: Removed `Reconnecting` from `ShardEvents` enum
2024-10-11 10:44:57 +03:00
Eejit
1925c11a48 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 04:24:08 +00:00
Denis Cristea
c36728a814 fix(Client): never pass token in ws constructor (#10544)
* fix(Client): never pass token in ws constructor

* chore: don't reassign parameter

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

---------

Co-authored-by: Almeida <github@almeidx.dev>
2024-10-09 10:49:27 +00:00
Naiyar
c8ef899a68 refactor(NewsChannel)!: rename NewsChannel to AnnouncementChannel (#10532)
BREAKING CHANGE: The `NewsChannel` class was renamed to `AnnouncementChannel`, in line with the type name change
2024-10-09 12:35:12 +03:00
Qjuh
a65c762950 refactor!: fully integrate /ws into mainlib (#10420)
BREAKING CHANGE: `Client#ws` is now a `@discordjs/ws#WebSocketManager`
BREAKING CHANGE: `WebSocketManager` and `WebSocketShard` are now re-exports from `@discordjs/ws`
BREAKING CHANGE: Removed the `WebSocketShardEvents` enum
BREAKING CHANGE: Renamed the `Client#ready` event to `Client#clientReady` event to not confuse it with the gateway `READY` event
BREAKING CHANGE: Added `Client#ping` to replace the old `WebSocketManager#ping`
BREAKING CHANGE: Removed the `Shard#reconnecting` event which wasn’t emitted anymore since 14.8.0 anyway
BREAKING CHANGE: Removed `ShardClientUtil#ids` and `ShardClientUtil#count` in favor of `Client#ws#getShardIds()` and `Client#ws#getShardCount()`
BREAKING CHANGE: `ClientUser#setPresence()` and `ClientPresence#set()` now return a Promise which resolves when the gateway call was sent successfully
BREAKING CHANGE: Removed `Guild#shard` as `WebSocketShard`s are now handled by `@discordjs/ws`
BREAKING CHANGE: Removed the following deprecated `Client` events: `raw`, `shardDisconnect`, `shardError`, `shardReady`, `shardReconnecting`, `shardResume` in favor of events from `@discordjs/ws#WebSocketManager`
BREAKING CHANGE: Removed `ClientOptions#shards` and `ClientOptions#shardCount` in favor of `ClientOptions#ws#shardIds` and `ClientOptions#ws#shardCount`
2024-10-08 22:41:25 +01:00
Denis Cristea
8ab4124ef9 feat: implement zod-validation-error (#10534)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-10-06 14:43:06 +00:00
pat
24128a3c45 test: replace jest with vitest (#10472)
* chore: vitest config

* feat: vitest

* fix: do not actually create ws

* chore: config

* chore: lockfile

* chore: revert downgrade, up node

* chore: package - 'git add -A'

* chore: delete mock-socket

* chore: delete mock-socket

* fix: lockfile

---------

Co-authored-by: almeidx <github@almeidx.dev>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-10-06 14:26:53 +00:00
Amgelo563
bb04e09f8b 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-06 14:23:44 +00:00
Danial Raza
04df3c4130 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>
2024-10-06 14:19:50 +00:00
Denis Cristea
12e510671b chore!: remove all deprecated features/props (#10421)
BREAKING CHANGE: Removed `Client#fetchPremiumStickerPacks` method
BREAKING CHANGE: Removed `Client#webhookUpdate` event
BREAKING CHANGE: Removed various error codes
BREAKING CHANGE: Removed `Formatters` namespace
BREAKING CHANGE: Removed `InviteStageInstance` class
BREAKING CHANGE: Removed `Invite#stageInstance` property
BREAKING CHANGE: Removed `StageInstance#discoverable_disabled` property
BREAKING CHANGE: Removed `SelectMenuBuilder` alias
BREAKING CHANGE: Removed `SelectMenuComponent` alias
BREAKING CHANGE: Removed `SelectMenuInteraction` alias
BREAKING CHANGE: Removed `SelectMenuOptionBuilder` alias
BREAKING CHANGE: Removed `BaseInteraction#isSelectMenu` alias
BREAKING CHANGE: Removed `deleteMessageDays` option from `GuildBanManager#create`
BREAKING CHANGE: Removed `ActionRow#from` method
BREAKING CHANGE: Removed `Emoji#url` getter
BREAKING CHANGE: Removed `TeamMember#permissions` property
BREAKING CHANGE: Removed `User#avatarDecoration` property
BREAKING CHANGE: Removed `InteractionResponses#sendPremiumRequired` method
BREAKING CHANGE: Removed `DeletableMessageTypes` constant
2024-10-04 14:17:34 +03:00
Superchupu
c1b849fa5a docs(discord.js): remove utf-8-validate (#10531) 2024-10-03 18:10:46 +00:00
René
b339a7cb08 fix(ThreadMember): remove audit log reason parameter (#10023)
fix(ThreadMember): remove audit log reason

Co-authored-by: René <Renegade334@users.noreply.github.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-10-01 16:33:25 +00:00
MrMythicalYT
05541d8288 fix(User): remove fetchFlags() (#8755)
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2024-10-01 16:29:31 +00:00
Rodrigo Leitão
493a079fdf refactor(CommandInteractionOptionResolver): remove getFull from getFocused() (#9789)
* refactor(CommandInteractionOptionResolver): remove getFull from getFocused()

* docs: update return type

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

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2024-10-01 16:21:42 +00:00
Denis Cristea
ab32f26cbb refactor: builders (#10448)
BREAKING CHANGE: formatters export removed (prev. deprecated)
BREAKING CHANGE: `SelectMenuBuilder` and `SelectMenuOptionBuilder` have been removed (prev. deprecated)
BREAKING CHANGE: `EmbedBuilder` no longer takes camalCase options
BREAKING CHANGE: `ActionRowBuilder` now has specialized `[add/set]X` methods as opposed to the current `[add/set]Components`
BREAKING CHANGE: Removed `equals` methods
BREAKING CHANGE: Sapphire -> zod for validation
BREAKING CHANGE: Removed the ability to pass `null`/`undefined` to clear fields, use `clearX()` instead
BREAKING CHANGE: Renamed all "slash command" symbols to instead use "chat input command"
BREAKING CHANGE: Removed `ContextMenuCommandBuilder` in favor of `MessageCommandBuilder` and `UserCommandBuilder`
BREAKING CHANGE: Removed support for passing the "string key"s of enums
BREAKING CHANGE: Removed `Button` class in favor for specialized classes depending on the style
BREAKING CHANGE: Removed nested `addX` styled-methods in favor of plural `addXs`

Co-authored-by: Vlad Frangu <me@vladfrangu.dev>
Co-authored-by: Almeida <github@almeidx.dev>
2024-10-01 19:11:56 +03:00
Moebits
c633d5c7f6 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-01 12:33:40 +00:00
Qjuh
b20346f430 chore: unpin discord-api-types (#10524)
* chore: unpin discord-api-types

* chore: bump discord-api-types
2024-10-01 10:07:58 +00:00
Almeida
9aa3b635ef 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-29 18:41:57 +00:00
TÆMBØ
e1012cc54a 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-29 11:35:40 +00:00
373 changed files with 6735 additions and 19388 deletions

View File

@@ -2,7 +2,7 @@ version = 1
[merge]
require_automerge_label = false
blocking_labels = ['blocked', 'in review']
blocking_labels = ['blocked', 'in review', 'semver:major']
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/articles/about-code-owners
# https://help.github.com/en/articles/about-code-owners
* @iCrawl

View File

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

View File

@@ -103,15 +103,7 @@ 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 }}
@@ -121,15 +113,7 @@ 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 }}
@@ -139,10 +123,6 @@ 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 }}
@@ -152,10 +132,6 @@ 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 }}
@@ -179,50 +155,26 @@ 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,9 +9,7 @@
<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://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>
<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,9 +18,8 @@ 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 || isExternal;
const isAny = isDeprecated || isProtected || isStatic || isAbstract || isReadonly || isOptional;
return isAny ? (
<div className="mb-1 flex gap-3">
@@ -34,7 +33,6 @@ 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/v14] --concurrency=4",
"build:affected": "turbo run build --filter=...[origin/main] --concurrency=4",
"build:apps": "turbo run build:local --filter=...{apps/*} --concurrency=4",
"build:apps:affected": "turbo run build:local --filter=...{apps/*}[origin/v14] --concurrency=4",
"build:apps:affected": "turbo run build:local --filter=...{apps/*}[origin/main] --concurrency=4",
"test": "turbo run test --concurrency=4",
"test:affected": "turbo run test --filter=...[origin/v14] --concurrency=4",
"test:affected": "turbo run test --filter=...[origin/main] --concurrency=4",
"lint": "turbo run lint --concurrency=4",
"lint:affected": "turbo run lint --filter=...[origin/v14] --concurrency=4",
"lint:affected": "turbo run lint --filter=...[origin/main] --concurrency=4",
"format": "turbo run format --concurrency=4",
"format:affected": "turbo run format --filter=...[origin/v14] --concurrency=4",
"format:affected": "turbo run format --filter=...[origin/main] --concurrency=4",
"fmt": "turbo run format --concurrency=4",
"fmt:affected": "turbo run format --filter=...[origin/v14] --concurrency=4",
"fmt:affected": "turbo run format --filter=...[origin/main] --concurrency=4",
"docs": "turbo run docs --concurrency=4",
"docs:affected": "turbo run docs --filter=...[origin/v14] --concurrency=4",
"docs:affected": "turbo run docs --filter=...[origin/main] --concurrency=4",
"prepare": "is-ci || husky",
"update": "pnpm --recursive update --interactive",
"update:latest": "pnpm --recursive update --interactive --latest",

View File

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

View File

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

View File

@@ -1,26 +1,13 @@
/* 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 ||
!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');
if (!process.env.DATABASE_URL) {
setFailed('DATABASE_URL is not set');
}
const pkg = getInput('package') || '*';
@@ -30,21 +17,6 @@ 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 = [];
@@ -54,14 +26,12 @@ 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 key = `${name.replace('@discordjs/', '')}/${version}.json`;
const { url } = await put(key, data, {
const { url } = await put(`${name.replace('@discordjs/', '')}/${version}.json`, data, {
access: 'public',
addRandomSuffix: false,
});
@@ -69,19 +39,6 @@ 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,82 +1,32 @@
/* eslint-disable @typescript-eslint/no-loop-func */
import { readFile } from 'node:fs/promises';
import { basename, dirname, relative, sep } from 'node:path';
import process from 'node:process';
import { setTimeout as sleep } from 'node:timers/promises';
import { setFailed, getInput } from '@actions/core';
import { cwd } from 'node:process';
import { getInput } from '@actions/core';
import { create } from '@actions/glob';
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { put } from '@vercel/blob';
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');
}
import pLimit from 'p-limit';
const pkg = getInput('package') || '*';
const version = getInput('version') || 'main';
const queue = new PQueue({ concurrency: 10, interval: 60_000, intervalCap: 1_000 });
const limit = pLimit(10);
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(process.cwd(), file)).split(sep)[1];
const pkgName = dirname(relative(cwd(), file)).split(sep)[1];
try {
promises.push(
queue.add(async () => {
// eslint-disable-next-line @typescript-eslint/no-loop-func
limit(async () => {
console.log(`Uploading ${file} with ${version} from ${pkgName}...`);
const name = basename(file).replace('main.', '');
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();
await put(`rewrite/${pkgName}/${version}.${name}`, data, {
access: 'public',
addRandomSuffix: false,
});
}),
);
} catch (error) {
@@ -86,9 +36,6 @@ 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

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

View File

@@ -2,7 +2,15 @@
// Copyright (c) Microsoft Corporation. All rights reserved. Licensed under the MIT license.
// See LICENSE in the project root for license information.
import { TSDocConfiguration } from '@microsoft/tsdoc';
import {
type DocNode,
type DocPlainText,
DocDeclarationReference,
DocNodeKind,
TSDocConfiguration,
DocMemberReference,
DocMemberIdentifier,
} 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';
@@ -42,6 +50,11 @@ export interface IApiItemContainerJson extends IApiItemJson {
preserveMemberOrder?: boolean;
}
interface Mixin {
declarationReference: DocDeclarationReference;
typeParameters: IExcerptTokenRange[];
}
interface ExcerptTokenRangeInDeclaredItem {
item: ApiDeclaredItem;
range: IExcerptTokenRange;
@@ -393,14 +406,52 @@ 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[] | undefined;
let extendsTypes: readonly (HeritageType | Mixin)[] | undefined;
switch (next.item.kind) {
case ApiItemKind.Class: {
const apiClass: ApiClass = next.item as ApiClass;
extendsTypes = apiClass.extendsType ? [apiClass.extendsType] : [];
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];
break;
}
@@ -425,30 +476,38 @@ export function ApiItemContainerMixin<TBaseClass extends IApiItemConstructor>(
}
for (const extendsType of extendsTypes) {
// 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;
},
);
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;
},
);
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;
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;
}
const apiModel: ApiModel | undefined = this.getAssociatedModel();
@@ -461,10 +520,9 @@ export function ApiItemContainerMixin<TBaseClass extends IApiItemConstructor>(
continue;
}
const canonicalReference: DeclarationReference = firstReferenceToken.canonicalReference!;
const apiItemResult: IResolveDeclarationReferenceResult = apiModel.resolveDeclarationReference(
canonicalReference,
undefined,
this,
);
const apiItem: ApiItem | undefined = apiItemResult.resolvedApiItem;

View File

@@ -294,7 +294,11 @@ export class ApiPackage extends ApiItemContainerMixin(ApiNameMixin(ApiDocumented
const tsdocConfiguration: TSDocConfiguration = new TSDocConfiguration();
if (versionToDeserialize >= ApiJsonSchemaVersion.V_1004 && 'tsdocConfiguration' in jsonObject) {
if (
versionToDeserialize >= ApiJsonSchemaVersion.V_1004 &&
'tsdocConfig' in jsonObject.metadata &&
'$schema' in jsonObject.metadata.tsdocConfig
) {
const tsdocConfigFile: TSDocConfigFile = TSDocConfigFile.loadFromObject(jsonObject.metadata.tsdocConfig);
if (tsdocConfigFile.hasErrors) {
throw new Error(`Error loading ${apiJsonFilename}:\n` + tsdocConfigFile.getErrorSummary());
@@ -421,6 +425,8 @@ 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];
@@ -441,6 +447,8 @@ 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,6 +32,10 @@
{
"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 === undefined ? '' : ` (default: ${this._escapeSpecialChars(jsDoc.default)})`}\n${
`/**\n * ${this._fixLinkTags(jsDoc.description) ?? ''}${jsDoc.default ? ` (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 === undefined ? '' : `\n * @defaultValue ${this._escapeSpecialChars(jsDoc.default)}`}\n${
`/**\n * ${this._fixLinkTags(jsDoc.description) ?? ''}${jsDoc.default ? `\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,8 +9,7 @@
<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://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>
<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,52 +2,6 @@
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,8 +9,7 @@
<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://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>
<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>
@@ -24,7 +23,7 @@
## Installation
**Node.js 16.11.0 or newer is required.**
**Node.js 18 or newer is required.**
```sh
npm install @discordjs/builders

View File

@@ -2,18 +2,18 @@ import {
ButtonStyle,
ComponentType,
type APIActionRowComponent,
type APIComponentInMessageActionRow,
type APIMessageActionRowComponent,
} 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<APIComponentInMessageActionRow> = {
const rowWithButtonData: APIActionRowComponent<APIMessageActionRowComponent> = {
type: ComponentType.ActionRow,
components: [
{
@@ -25,7 +25,7 @@ const rowWithButtonData: APIActionRowComponent<APIComponentInMessageActionRow> =
],
};
const rowWithSelectMenuData: APIActionRowComponent<APIComponentInMessageActionRow> = {
const rowWithSelectMenuData: APIActionRowComponent<APIMessageActionRowComponent> = {
type: ComponentType.ActionRow,
components: [
{
@@ -41,23 +41,16 @@ const rowWithSelectMenuData: APIActionRowComponent<APIComponentInMessageActionRo
value: 'two',
},
],
max_values: 10,
min_values: 12,
max_values: 2,
min_values: 2,
},
],
};
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<APIComponentInMessageActionRow> = {
const actionRowData: APIActionRowComponent<APIMessageActionRowComponent> = {
type: ComponentType.ActionRow,
components: [
{
@@ -72,27 +65,15 @@ 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<APIComponentInMessageActionRow> = {
const rowWithButtonData: APIActionRowComponent<APIMessageActionRowComponent> = {
type: ComponentType.ActionRow,
components: [
{
@@ -104,7 +85,7 @@ describe('Action Row Components', () => {
],
};
const rowWithSelectMenuData: APIActionRowComponent<APIComponentInMessageActionRow> = {
const rowWithSelectMenuData: APIActionRowComponent<APIMessageActionRowComponent> = {
type: ComponentType.ActionRow,
components: [
{
@@ -120,24 +101,23 @@ describe('Action Row Components', () => {
value: 'two',
},
],
max_values: 10,
min_values: 12,
max_values: 1,
min_values: 1,
},
],
};
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 ButtonBuilder().setLabel('test').setStyle(ButtonStyle.Primary).setCustomId('123');
const button = new PrimaryButtonBuilder().setLabel('test').setCustomId('123');
const selectMenu = new StringSelectMenuBuilder()
.setCustomId('1234')
.setMaxValues(10)
.setMinValues(12)
.setMaxValues(2)
.setMinValues(2)
.setOptions(
new StringSelectMenuOptionBuilder().setLabel('one').setValue('one'),
new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'),
@@ -147,10 +127,39 @@ describe('Action Row Components', () => {
new StringSelectMenuOptionBuilder().setLabel('two').setValue('two'),
]);
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);
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();
});
});
});

View File

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

View File

@@ -3,7 +3,7 @@ import {
ComponentType,
TextInputStyle,
type APIButtonComponent,
type APIComponentInMessageActionRow,
type APIMessageActionRowComponent,
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([ButtonBuilder, StringSelectMenuBuilder, TextInputBuilder])(
test.each([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<APIComponentInMessageActionRow> = {
const actionRow: APIActionRowComponent<APIMessageActionRowComponent> = {
components: [],
type: ComponentType.ActionRow,
};
@@ -42,7 +42,7 @@ describe('createComponentBuilder', () => {
type: ComponentType.Button,
};
expect(createComponentBuilder(button)).toBeInstanceOf(ButtonBuilder);
expect(createComponentBuilder(button)).toBeInstanceOf(CustomIdButtonBuilder);
});
test('GIVEN a select menu component THEN returns a StringSelectMenuBuilder', () => {

View File

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

View File

@@ -1,13 +1,5 @@
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);
@@ -16,56 +8,6 @@ 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();
@@ -84,9 +26,7 @@ describe('Text Input Components', () => {
}).not.toThrowError();
expect(() => {
// Issue #8107
// @ts-expect-error: Shapeshift maps the enum key to the value when parsing
textInputComponent().setCustomId('Custom').setLabel('Guess').setStyle('Short').toJSON();
textInputComponent().setCustomId('Custom').setLabel('Guess').setStyle(TextInputStyle.Short).toJSON();
}).not.toThrowError();
});
});
@@ -100,11 +40,11 @@ describe('Text Input Components', () => {
.setPlaceholder('hello')
.setStyle(TextInputStyle.Paragraph)
.toJSON();
}).not.toThrowError();
}).toThrowError();
});
test('GIVEN valid input THEN valid JSON outputs are given', () => {
const textInputData = {
const textInputData: APITextInputComponent = {
type: ComponentType.TextInput,
label: 'label',
custom_id: 'custom id',
@@ -114,7 +54,7 @@ describe('Text Input Components', () => {
value: 'value',
required: false,
style: TextInputStyle.Paragraph,
} satisfies APITextInputComponent;
};
expect(new TextInputBuilder(textInputData).toJSON()).toEqual(textInputData);
expect(

View File

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

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

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

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

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

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

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

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

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

View File

@@ -1,72 +1,31 @@
import { ApplicationIntegrationType, InteractionContextType, PermissionFlagsBits } from 'discord-api-types/v10';
import {
ApplicationCommandType,
ApplicationIntegrationType,
InteractionContextType,
PermissionFlagsBits,
} from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { ContextMenuCommandAssertions, ContextMenuCommandBuilder } from '../../src/index.js';
import { MessageContextCommandBuilder } from '../../src/index.js';
const getBuilder = () => new ContextMenuCommandBuilder();
const getBuilder = () => new MessageContextCommandBuilder();
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 valid builder THEN does not throw error', () => {
expect(() => getBuilder().setName('example').setType(3).toJSON()).not.toThrowError();
});
test('GIVEN invalid name THEN throw error', () => {
expect(() => getBuilder().setName(' ')).toThrowError();
expect(() => getBuilder().setName('$$$').toJSON()).toThrowError();
expect(() => getBuilder().setName(' ').toJSON()).toThrowError();
});
test('GIVEN valid names THEN does not throw error', () => {
expect(() => getBuilder().setName('hi_there')).not.toThrowError();
expect(() => getBuilder().setName('hi_there').toJSON()).not.toThrowError();
expect(() => getBuilder().setName('A COMMAND')).not.toThrowError();
expect(() => getBuilder().setName('A COMMAND').toJSON()).not.toThrowError();
// Translation: a_command
expect(() => getBuilder().setName('o_comandă')).not.toThrowError();
@@ -74,20 +33,6 @@ 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', () => {
@@ -104,19 +49,22 @@ describe('Context Menu Commands', () => {
test('GIVEN invalid name localizations THEN does throw error', () => {
// @ts-expect-error: Invalid localization
expect(() => getBuilder().setNameLocalization('en-U', 'foobar')).toThrowError();
expect(() => getBuilder().setNameLocalization('en-U', 'foobar').toJSON()).toThrowError();
// @ts-expect-error: Invalid localization
expect(() => getBuilder().setNameLocalizations({ 'en-U': 'foobar' })).toThrowError();
expect(() => getBuilder().setNameLocalizations({ 'en-U': 'foobar' }).toJSON()).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().setName('hi').setNameLocalization('en-US', 'foobar').toJSON().name_localizations).toEqual(
expectedSingleLocale,
);
expect(getBuilder().setNameLocalizations(null).name_localizations).toBeNull();
expect(getBuilder().setNameLocalization('en-US', null).name_localizations).toEqual({
'en-US': null,
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,
});
});
});
@@ -132,14 +80,10 @@ describe('Context Menu Commands', () => {
).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().setName('hi').setDefaultMemberPermissions('1.1').toJSON()).toThrowError();
expect(() => getBuilder().setDefaultMemberPermissions(1.1)).toThrowError();
expect(() => getBuilder().setName('hi').setDefaultMemberPermissions(1.1).toJSON()).toThrowError();
});
});
@@ -156,10 +100,10 @@ describe('Context Menu Commands', () => {
test('GIVEN a builder with invalid contexts THEN does throw an error', () => {
// @ts-expect-error: Invalid contexts
expect(() => getBuilder().setContexts(999)).toThrowError();
expect(() => getBuilder().setName('hi').setContexts(999).toJSON()).toThrowError();
// @ts-expect-error: Invalid contexts
expect(() => getBuilder().setContexts([999, 998])).toThrowError();
expect(() => getBuilder().setName('hi').setContexts([999, 998]).toJSON()).toThrowError();
});
});
@@ -182,10 +126,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().setIntegrationTypes(999)).toThrowError();
expect(() => getBuilder().setName('hi').setIntegrationTypes(999).toJSON()).toThrowError();
// @ts-expect-error: Invalid integration types
expect(() => getBuilder().setIntegrationTypes([999, 998])).toThrowError();
expect(() => getBuilder().setName('hi').setIntegrationTypes([999, 998]).toJSON()).toThrowError();
});
});
});

View File

@@ -1,593 +0,0 @@
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,71 +1,21 @@
import {
ComponentType,
TextInputStyle,
type APIModalInteractionResponseCallbackData,
type APITextInputComponent,
} from 'discord-api-types/v10';
import { ComponentType, TextInputStyle, type APIModalInteractionResponseCallbackData } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import {
ActionRowBuilder,
ButtonBuilder,
ModalBuilder,
TextInputBuilder,
type ModalActionRowComponentBuilder,
} from '../../src/index.js';
import {
componentsValidator,
titleValidator,
validateRequiredParameters,
} from '../../src/interactions/modals/Assertions.js';
import { ActionRowBuilder, ModalBuilder, TextInputBuilder } from '../../src/index.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').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();
expect(() => modal().setTitle('test').setCustomId('foobar').setActionRows(textInput()).toJSON()).not.toThrowError();
expect(() => modal().setTitle('test').setCustomId('foobar').addActionRows(textInput()).toJSON()).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();
});
@@ -106,68 +56,17 @@ describe('Modals', () => {
modal()
.setTitle(modalData.title)
.setCustomId('custom id')
.setComponents(
new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(
.setActionRows(
new ActionRowBuilder().addTextInputComponent(
new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph),
),
)
.addComponents([
new ActionRowBuilder<ModalActionRowComponentBuilder>().addComponents(
.addActionRows([
new ActionRowBuilder().addTextInputComponent(
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,6 +3,16 @@ 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', () => {
@@ -14,127 +24,136 @@ describe('Embed', () => {
footer: { text: alpha },
});
expect(embedLength(embed.data)).toEqual(alpha.length * 6);
expect(embedLength(embed.toJSON())).toEqual(alpha.length * 6);
});
test('GIVEN an embed with zero characters THEN returns amount of characters', () => {
const embed = new EmbedBuilder();
expect(embedLength(embed.data)).toEqual(0);
expect(embedLength(embed.toJSON(false))).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({ title: 'foo' });
expect(embed.toJSON()).toStrictEqual({ ...base, 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({ title: 'foo' });
expect(embed.toJSON()).toStrictEqual({ ...base, 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' });
embed.setTitle(null);
const embed = new EmbedBuilder({ title: 'foo', description: ':3' });
embed.clearTitle();
expect(embed.toJSON()).toStrictEqual({ title: undefined });
expect(embed.toJSON()).toStrictEqual({ ...base, description: ':3', title: undefined });
});
test('GIVEN an embed with an invalid title THEN throws error', () => {
const embed = new EmbedBuilder();
expect(() => embed.setTitle('a'.repeat(257))).toThrowError();
embed.setTitle('a'.repeat(257));
expect(() => embed.toJSON()).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({ description: 'foo' });
expect(embed.toJSON()).toStrictEqual({ ...base, 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({ description: 'foo' });
expect(embed.toJSON()).toStrictEqual({ ...base, 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' });
embed.setDescription(null);
const embed = new EmbedBuilder({ description: 'foo', ...dummy });
embed.clearDescription();
expect(embed.toJSON()).toStrictEqual({ description: undefined });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, description: undefined });
});
test('GIVEN an embed with an invalid description THEN throws error', () => {
const embed = new EmbedBuilder();
expect(() => embed.setDescription('a'.repeat(4_097))).toThrowError();
embed.setDescription('a'.repeat(4_097));
expect(() => embed.toJSON()).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/' });
const embed = new EmbedBuilder({ url: 'https://discord.js.org/', ...dummy });
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();
const embed = new EmbedBuilder(dummy);
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' });
embed.setURL(null);
const embed = new EmbedBuilder({ url: 'https://discord.js.org', ...dummy });
embed.clearURL();
expect(embed.toJSON()).toStrictEqual({ url: undefined });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, url: undefined });
});
test.each(['owo', 'discord://user'])('GIVEN an embed with an invalid URL THEN throws error', (input) => {
const embed = new EmbedBuilder();
expect(() => embed.setURL(input)).toThrowError();
embed.setURL(input);
expect(() => embed.toJSON()).toThrowError();
});
});
describe('Embed Color', () => {
test('GIVEN an embed with a pre-defined color THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder({ color: 0xff0000 });
expect(embed.toJSON()).toStrictEqual({ color: 0xff0000 });
const embed = new EmbedBuilder({ color: 0xff0000, ...dummy });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, color: 0xff0000 });
});
test('GIVEN an embed using Embed#setColor THEN returns valid toJSON data', () => {
expect(new EmbedBuilder().setColor(0xff0000).toJSON()).toStrictEqual({ color: 0xff0000 });
expect(new EmbedBuilder().setColor([242, 66, 245]).toJSON()).toStrictEqual({ color: 0xf242f5 });
expect(new EmbedBuilder(dummy).setColor(0xff0000).toJSON()).toStrictEqual({ ...base, ...dummy, color: 0xff0000 });
});
test('GIVEN an embed with a pre-defined color THEN unset color THEN return valid toJSON data', () => {
const embed = new EmbedBuilder({ color: 0xff0000 });
embed.setColor(null);
const embed = new EmbedBuilder({ ...dummy, color: 0xff0000 });
embed.clearColor();
expect(embed.toJSON()).toStrictEqual({ color: undefined });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, color: undefined });
});
test('GIVEN an embed with an invalid color THEN throws error', () => {
const embed = new EmbedBuilder();
// @ts-expect-error: Invalid color
expect(() => embed.setColor('RED')).toThrowError();
embed.setColor('RED');
expect(() => embed.toJSON()).toThrowError();
// @ts-expect-error: Invalid color
expect(() => embed.setColor([42, 36])).toThrowError();
expect(() => embed.setColor([42, 36, 1_000])).toThrowError();
embed.setColor([42, 36]);
expect(() => embed.toJSON()).toThrowError();
});
});
@@ -142,98 +161,92 @@ 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() });
expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() });
const embed = new EmbedBuilder({ timestamp: now.toISOString(), ...dummy });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: now.toISOString() });
});
test('given an embed using Embed#setTimestamp (with Date) THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
test('GIVEN an embed using Embed#setTimestamp (with Date) THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder(dummy);
embed.setTimestamp(now);
expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: now.toISOString() });
});
test('GIVEN an embed using Embed#setTimestamp (with int) THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
const embed = new EmbedBuilder(dummy);
embed.setTimestamp(now.getTime());
expect(embed.toJSON()).toStrictEqual({ timestamp: now.toISOString() });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: now.toISOString() });
});
test('GIVEN an embed using Embed#setTimestamp (default) THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
const embed = new EmbedBuilder(dummy);
embed.setTimestamp();
expect(embed.toJSON()).toStrictEqual({ timestamp: embed.data.timestamp });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, timestamp: embed.toJSON().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() });
embed.setTimestamp(null);
const embed = new EmbedBuilder({ timestamp: now.toISOString(), ...dummy });
embed.clearTimestamp();
expect(embed.toJSON()).toStrictEqual({ timestamp: undefined });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, 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({
thumbnail: { url: 'https://discord.js.org/static/logo.svg' },
});
expect(embed.toJSON()).toStrictEqual({ ...base, 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({
thumbnail: { url: 'https://discord.js.org/static/logo.svg' },
});
expect(embed.toJSON()).toStrictEqual({ ...base, 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' } });
embed.setThumbnail(null);
const embed = new EmbedBuilder({ thumbnail: { url: 'https://discord.js.org/static/logo.svg' }, ...dummy });
embed.clearThumbnail();
expect(embed.toJSON()).toStrictEqual({ thumbnail: undefined });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, thumbnail: undefined });
});
test('GIVEN an embed with an invalid thumbnail THEN throws error', () => {
const embed = new EmbedBuilder();
expect(() => embed.setThumbnail('owo')).toThrowError();
embed.setThumbnail('owo');
expect(() => embed.toJSON()).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({
image: { url: 'https://discord.js.org/static/logo.svg' },
});
expect(embed.toJSON()).toStrictEqual({ ...base, 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({
image: { url: 'https://discord.js.org/static/logo.svg' },
});
expect(embed.toJSON()).toStrictEqual({ ...base, 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' } });
embed.setImage(null);
const embed = new EmbedBuilder({ image: { url: 'https://discord.js/org/static/logo.svg' }, ...dummy });
embed.clearImage();
expect(embed.toJSON()).toStrictEqual({ image: undefined });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, image: undefined });
});
test('GIVEN an embed with an invalid image THEN throws error', () => {
const embed = new EmbedBuilder();
expect(() => embed.setImage('owo')).toThrowError();
embed.setImage('owo');
expect(() => embed.toJSON()).toThrowError();
});
});
@@ -243,19 +256,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({
name: 'Wumpus',
iconURL: 'https://discord.js.org/static/logo.svg',
url: 'https://discord.js.org',
});
embed.setAuthor((author) =>
author.setName('Wumpus').setIconURL('https://discord.js.org/static/logo.svg').setURL('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' },
});
});
@@ -263,16 +276,18 @@ 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.setAuthor(null);
embed.clearAuthor();
expect(embed.toJSON()).toStrictEqual({ author: undefined });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, author: undefined });
});
test('GIVEN an embed with an invalid author name THEN throws error', () => {
const embed = new EmbedBuilder();
expect(() => embed.setAuthor({ name: 'a'.repeat(257) })).toThrowError();
embed.setAuthor({ name: 'a'.repeat(257) });
expect(() => embed.toJSON()).toThrowError();
});
});
@@ -282,32 +297,36 @@ 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', iconURL: 'https://discord.js.org/static/logo.svg' });
embed.setFooter({ 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 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.setFooter(null);
embed.clearFooter();
expect(embed.toJSON()).toStrictEqual({ footer: undefined });
expect(embed.toJSON()).toStrictEqual({ ...base, ...dummy, footer: undefined });
});
test('GIVEN an embed with invalid footer text THEN throws error', () => {
const embed = new EmbedBuilder();
expect(() => embed.setFooter({ text: 'a'.repeat(2_049) })).toThrowError();
embed.setFooter({ text: 'a'.repeat(2_049) });
expect(() => embed.toJSON()).toThrowError();
});
});
@@ -316,24 +335,19 @@ describe('Embed', () => {
const embed = new EmbedBuilder({
fields: [{ name: 'foo', value: 'bar' }],
});
expect(embed.toJSON()).toStrictEqual({
fields: [{ name: 'foo', value: 'bar' }],
});
expect(embed.toJSON()).toStrictEqual({ ...base, 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' },
{ name: '', value: '' },
]);
embed.addFields([{ name: 'foo', value: 'bar' }]);
expect(embed.toJSON()).toStrictEqual({
...base,
fields: [
{ name: 'foo', value: 'bar' },
{ name: 'foo', value: 'bar' },
{ name: '', value: '' },
],
});
});
@@ -342,67 +356,79 @@ describe('Embed', () => {
const embed = new EmbedBuilder();
embed.addFields({ name: 'foo', value: 'bar' }, { name: 'foo', value: 'baz' });
expect(embed.spliceFields(0, 1).toJSON()).toStrictEqual({
fields: [{ name: 'foo', value: 'baz' }],
});
expect(embed.spliceFields(0, 1).toJSON()).toStrictEqual({ ...base, 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' })));
expect(() =>
embed.spliceFields(0, 3, ...Array.from({ length: 5 }, () => ({ name: 'foo', value: 'bar' }))),
).not.toThrowError();
embed.spliceFields(0, 3, ...Array.from({ length: 5 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).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' })));
expect(() =>
embed.spliceFields(0, 3, ...Array.from({ length: 8 }, () => ({ name: 'foo', value: 'bar' }))),
).toThrowError();
embed.spliceFields(0, 3, ...Array.from({ length: 8 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).toThrowError();
});
test('GIVEN an embed using Embed#setFields THEN returns valid toJSON data', () => {
const embed = new EmbedBuilder();
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();
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();
});
test('GIVEN an embed using Embed#setFields that sets more than 25 fields THEN throws error', () => {
const embed = new EmbedBuilder();
expect(() =>
embed.setFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))),
).toThrowError();
expect(() => embed.setFields(Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })))).toThrowError();
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();
});
test('GIVEN invalid field amount THEN throws error', () => {
const embed = new EmbedBuilder();
describe('GIVEN invalid field amount THEN throws error', () => {
test('1', () => {
const embed = new EmbedBuilder();
expect(() =>
embed.addFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' }))),
).toThrowError();
embed.addFields(...Array.from({ length: 26 }, () => ({ name: 'foo', value: 'bar' })));
expect(() => embed.toJSON()).toThrowError();
});
});
test('GIVEN invalid field name length THEN throws error', () => {
const embed = new EmbedBuilder();
describe('GIVEN invalid field name THEN throws error', () => {
test('2', () => {
const embed = new EmbedBuilder();
expect(() => embed.addFields({ name: 'a'.repeat(257), value: 'bar' })).toThrowError();
embed.addFields({ name: '', value: 'bar' });
expect(() => embed.toJSON()).toThrowError();
});
});
test('GIVEN invalid field value length THEN throws error', () => {
const embed = new EmbedBuilder();
describe('GIVEN invalid field name length THEN throws error', () => {
test('3', () => {
const embed = new EmbedBuilder();
expect(() => embed.addFields({ name: '', value: 'a'.repeat(1_025) })).toThrowError();
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();
});
});
});
});

View File

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

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@discordjs/builders",
"version": "1.13.0",
"version": "1.9.0",
"description": "A set of builders that you can use when creating your bot",
"scripts": {
"test": "vitest run",
@@ -65,19 +65,18 @@
"homepage": "https://discord.js.org",
"funding": "https://github.com/discordjs/discord.js?sponsor",
"dependencies": {
"@discordjs/formatters": "workspace:^",
"@discordjs/util": "workspace:^",
"@sapphire/shapeshift": "^4.0.0",
"discord-api-types": "^0.38.32",
"fast-deep-equal": "^3.1.3",
"discord-api-types": "^0.37.103",
"ts-mixer": "^6.0.4",
"tslib": "^2.6.3"
"tslib": "^2.6.3",
"zod": "^3.23.8",
"zod-validation-error": "^3.4.0"
},
"devDependencies": {
"@discordjs/api-extractor": "workspace:^",
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@types/node": "^16.18.105",
"@types/node": "^18.19.44",
"@vitest/coverage-v8": "^2.0.5",
"cross-env": "^7.0.3",
"esbuild-plugin-version-injector": "^1.2.1",
@@ -91,7 +90,7 @@
"vitest": "^2.0.5"
},
"engines": {
"node": ">=16.11.0"
"node": ">=18"
},
"publishConfig": {
"access": "public",

View File

@@ -0,0 +1,20 @@
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,61 +1,60 @@
/* eslint-disable jsdoc/check-param-names */
import {
type APIActionRowComponent,
ComponentType,
type APIComponentInMessageActionRow,
type APIComponentInModalActionRow,
type APIComponentInActionRow,
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 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';
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';
/**
* 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;
export interface ActionRowBuilderData
extends Partial<Omit<APIActionRowComponent<APIActionRowComponentTypes>, 'components'>> {
components: AnyActionRowComponentBuilder[];
}
/**
* A builder that creates API-compatible JSON data for action rows.
*
* @typeParam ComponentType - The types of components this action row holds
*/
export class ActionRowBuilder<ComponentType extends AnyComponentBuilder> extends ComponentBuilder<
APIActionRowComponent<APIComponentInMessageActionRow | APIComponentInModalActionRow>
> {
export class ActionRowBuilder extends ComponentBuilder<APIActionRowComponent<APIActionRowComponentTypes>> {
private readonly data: ActionRowBuilderData;
/**
* The components within this action row.
*/
public readonly components: ComponentType[];
public get components(): readonly AnyActionRowComponentBuilder[] {
return this.data.components;
}
/**
* Creates a new action row from API data.
@@ -91,38 +90,254 @@ export class ActionRowBuilder<ComponentType extends AnyComponentBuilder> extends
* .addComponents(button2, button3);
* ```
*/
public constructor({ components, ...data }: Partial<APIActionRowComponent<APIComponentInActionRow>> = {}) {
super({ type: ComponentType.ActionRow, ...data });
this.components = (components?.map((component) => createComponentBuilder(component)) ?? []) as ComponentType[];
public constructor({ components = [], ...data }: Partial<APIActionRowComponent<APIActionRowComponentTypes>> = {}) {
super();
this.data = {
...structuredClone(data),
type: ComponentType.ActionRow,
components: components.map((component) => createComponentBuilder(component)),
};
}
/**
* Adds components to this action row.
* Adds primary button components to this action row.
*
* @param components - The components to add
* @param input - The buttons to add
*/
public addComponents(...components: RestOrArray<ComponentType>) {
this.components.push(...normalizeArray(components));
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);
return this;
}
/**
* Sets components for this action row.
* Adds secondary button components to this action row.
*
* @param components - The components to set
* @param input - The buttons to add
*/
public setComponents(...components: RestOrArray<ComponentType>) {
this.components.splice(0, this.components.length, ...normalizeArray(components));
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);
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): APIActionRowComponent<ReturnType<ComponentType['toJSON']>> {
return {
...this.data,
components: this.components.map((component) => component.toJSON()),
} as APIActionRowComponent<ReturnType<ComponentType['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>;
}
}

View File

@@ -1,134 +1,168 @@
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';
import { ButtonStyle, ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate, refineURLPredicate } from '../Assertions.js';
export const idValidator = s
.number()
.safeInt()
.greaterThanOrEqual(1)
.lessThan(4_294_967_296) // 2^32 - 1
.setValidationEnabled(isValidationEnabled);
const labelPredicate = z.string().min(1).max(80);
export const customIdValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(100)
.setValidationEnabled(isValidationEnabled);
export const emojiValidator = s
export const emojiPredicate = z
.object({
id: s.string(),
name: s.string(),
animated: s.boolean(),
id: z.string().optional(),
name: z.string().min(2).max(32).optional(),
animated: z.boolean().optional(),
})
.partial()
.strict()
.setValidationEnabled(isValidationEnabled);
.refine((data) => data.id !== undefined || data.name !== undefined, {
message: "Either 'id' or 'name' must be provided",
});
export const disabledValidator = s.boolean();
const buttonPredicateBase = z.object({
type: z.literal(ComponentType.Button),
disabled: z.boolean().optional(),
});
export const buttonLabelValidator = s
.string()
.lengthGreaterThanOrEqual(1)
.lengthLessThanOrEqual(80)
.setValidationEnabled(isValidationEnabled);
const buttonCustomIdPredicateBase = buttonPredicateBase.extend({
custom_id: customIdPredicate,
emoji: emojiPredicate.optional(),
label: labelPredicate,
});
export const buttonStyleValidator = s.nativeEnum(ButtonStyle);
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 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(),
const buttonLinkPredicate = buttonPredicateBase
.extend({
style: z.literal(ButtonStyle.Link),
url: z
.string()
.url()
.refine(refineURLPredicate(['http:', 'https:', 'discord:'])),
emoji: emojiPredicate.optional(),
label: labelPredicate,
})
.setValidationEnabled(isValidationEnabled);
.strict();
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:'],
const buttonPremiumPredicate = buttonPredicateBase
.extend({
style: z.literal(ButtonStyle.Premium),
sku_id: z.string(),
})
.setValidationEnabled(isValidationEnabled);
.strict();
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.');
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);
}
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.');
if (menu.min_values !== undefined && menu.options.length < menu.min_values) {
addIssue('min_values', menu.min_values);
}
});
if (url && customId) {
throw new RangeError('URL and custom id are mutually exclusive.');
}
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 (!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.');
}
}
}
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),
]),
});

View File

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

View File

@@ -1,42 +1,65 @@
import type { JSONEncodable } from '@discordjs/util';
import { ComponentType, type APIMessageComponent, type APIModalComponent } from 'discord-api-types/v10';
import {
ActionRowBuilder,
type MessageActionRowComponentBuilder,
type AnyComponentBuilder,
type ModalComponentBuilder,
} from './ActionRow.js';
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 { ButtonBuilder } from './button/Button.js';
import { FileUploadBuilder } from './fileUpload/FileUpload.js';
import { LabelBuilder } from './label/Label.js';
import type { BaseButtonBuilder } from './button/Button.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';
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>
| ContainerBuilder
| FileBuilder
| MediaGalleryBuilder
| MessageActionRowComponentBuilder
| SectionBuilder
| SeparatorBuilder
| TextDisplayBuilder
| ThumbnailBuilder;
export type MessageComponentBuilder = ActionRowBuilder | MessageActionRowComponentBuilder;
/**
* The builders that may be used for modals.
*/
export type ModalComponentBuilder = ActionRowBuilder | ModalActionRowComponentBuilder;
/**
* Any button builder
*/
export type ButtonBuilder =
| DangerButtonBuilder
| LinkButtonBuilder
| PremiumButtonBuilder
| PrimaryButtonBuilder
| SecondaryButtonBuilder
| SuccessButtonBuilder;
/**
* The builders that may be used within an action row for messages.
*/
export type MessageActionRowComponentBuilder =
| ButtonBuilder
| ChannelSelectMenuBuilder
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| UserSelectMenuBuilder;
/**
* The builders that may be used within an action row for modals.
*/
export type ModalActionRowComponentBuilder = TextInputBuilder;
/**
* Any action row component builder.
*/
export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder;
/**
* Components here are mapped to their respective builder.
@@ -45,9 +68,9 @@ export interface MappedComponentTypes {
/**
* The action row component type is associated with an {@link ActionRowBuilder}.
*/
[ComponentType.ActionRow]: ActionRowBuilder<AnyComponentBuilder>;
[ComponentType.ActionRow]: ActionRowBuilder;
/**
* The button component type is associated with a {@link ButtonBuilder}.
* The button component type is associated with a {@link BaseButtonBuilder}.
*/
[ComponentType.Button]: ButtonBuilder;
/**
@@ -74,42 +97,6 @@ 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;
}
/**
@@ -135,7 +122,7 @@ export function createComponentBuilder<ComponentBuilder extends MessageComponent
export function createComponentBuilder(
data: APIMessageComponent | APIModalComponent | MessageComponentBuilder,
): ComponentBuilder {
): ComponentBuilder<AnyAPIActionRowComponent> {
if (data instanceof ComponentBuilder) {
return data;
}
@@ -144,7 +131,7 @@ export function createComponentBuilder(
case ComponentType.ActionRow:
return new ActionRowBuilder(data);
case ComponentType.Button:
return new ButtonBuilder(data);
return createButtonBuilder(data);
case ComponentType.StringSelect:
return new StringSelectMenuBuilder(data);
case ComponentType.TextInput:
@@ -157,48 +144,28 @@ 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 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;
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}`);
}
if (typeof builder === 'function') {
return builder(new Constructor());
}
return new Constructor(builder);
}

View File

@@ -1,115 +1,13 @@
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 type { APIButtonComponent } from 'discord-api-types/v10';
import { validate } from '../../util/validation.js';
import { buttonPredicate } from '../Assertions.js';
import { ComponentBuilder } from '../Component.js';
/**
* A builder that creates API-compatible JSON data for buttons.
*/
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;
}
export abstract class BaseButtonBuilder<ButtonData extends APIButtonComponent> extends ComponentBuilder<ButtonData> {
protected declare readonly data: Partial<ButtonData>;
/**
* Sets whether this button is disabled.
@@ -117,35 +15,17 @@ export class ButtonBuilder extends ComponentBuilder<APIButtonComponent> {
* @param disabled - Whether to disable this button
*/
public setDisabled(disabled = true) {
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);
this.data.disabled = disabled;
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
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,
);
public override toJSON(validationOverride?: boolean): ButtonData {
const clone = structuredClone(this.data);
validate(buttonPredicate, clone, validationOverride);
return {
...this.data,
} as APIButtonComponent;
return clone as ButtonData;
}
}

View File

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

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

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

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

View File

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

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

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

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

@@ -1,92 +0,0 @@
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,23 +1,35 @@
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<
SelectMenuType extends APISelectMenuComponent,
> extends ComponentBuilder<SelectMenuType> {
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'>
>;
/**
* Sets the placeholder for this select menu.
*
* @param placeholder - The placeholder to use
*/
public setPlaceholder(placeholder: string) {
this.data.placeholder = placeholderValidator.parse(placeholder);
this.data.placeholder = placeholder;
return this;
}
/**
* Clears the placeholder for this select menu.
*/
public clearPlaceholder() {
this.data.placeholder = undefined;
return this;
}
@@ -27,17 +39,17 @@ export abstract class BaseSelectMenuBuilder<
* @param minValues - The minimum values that must be selected
*/
public setMinValues(minValues: number) {
this.data.min_values = minMaxValidator.parse(minValues);
this.data.min_values = minValues;
return this;
}
/**
* Sets the maximum values that can be selected in the select menu.
* Sets the maximum values that must be selected in the select menu.
*
* @param maxValues - The maximum values that can be selected
* @param maxValues - The maximum values that must be selected
*/
public setMaxValues(maxValues: number) {
this.data.max_values = minMaxValidator.parse(maxValues);
this.data.max_values = maxValues;
return this;
}
@@ -47,7 +59,7 @@ export abstract class BaseSelectMenuBuilder<
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customIdValidator.parse(customId);
this.data.custom_id = customId;
return this;
}
@@ -57,28 +69,7 @@ export abstract class BaseSelectMenuBuilder<
* @param disabled - Whether this select menu is disabled
*/
public setDisabled(disabled = true) {
this.data.disabled = disabledValidator.parse(disabled);
this.data.disabled = 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,13 +6,16 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { channelTypesValidator, customIdValidator, optionsLengthValidator } from '../Assertions.js';
import { validate } from '../../util/validation.js';
import { selectMenuChannelPredicate } 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.
*
@@ -36,8 +39,9 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
* .setMinValues(2);
* ```
*/
public constructor(data?: Partial<APIChannelSelectComponent>) {
super({ ...data, type: ComponentType.ChannelSelect });
public constructor(data: Partial<APIChannelSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.ChannelSelect };
}
/**
@@ -48,7 +52,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(...channelTypesValidator.parse(normalizedTypes));
this.data.channel_types.push(...normalizedTypes);
return this;
}
@@ -60,7 +64,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, ...channelTypesValidator.parse(normalizedTypes));
this.data.channel_types.splice(0, this.data.channel_types.length, ...normalizedTypes);
return this;
}
@@ -71,7 +75,6 @@ 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(
@@ -91,7 +94,6 @@ 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,
@@ -102,13 +104,12 @@ export class ChannelSelectMenuBuilder extends BaseSelectMenuBuilder<APIChannelSe
}
/**
* {@inheritDoc BaseSelectMenuBuilder.toJSON}
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(): APIChannelSelectComponent {
customIdValidator.parse(this.data.custom_id);
public override toJSON(validationOverride?: boolean): APIChannelSelectComponent {
const clone = structuredClone(this.data);
validate(selectMenuChannelPredicate, clone, validationOverride);
return {
...this.data,
} as APIChannelSelectComponent;
return clone as APIChannelSelectComponent;
}
}

View File

@@ -6,13 +6,16 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { optionsLengthValidator } from '../Assertions.js';
import { validate } from '../../util/validation.js';
import { selectMenuMentionablePredicate } 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.
*
@@ -35,8 +38,9 @@ export class MentionableSelectMenuBuilder extends BaseSelectMenuBuilder<APIMenti
* .setMinValues(1);
* ```
*/
public constructor(data?: Partial<APIMentionableSelectComponent>) {
super({ ...data, type: ComponentType.MentionableSelect });
public constructor(data: Partial<APIMentionableSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.MentionableSelect };
}
/**
@@ -46,7 +50,6 @@ 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(
@@ -66,7 +69,6 @@ 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(
@@ -91,7 +93,6 @@ 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;
@@ -109,8 +110,17 @@ 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,13 +5,16 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { optionsLengthValidator } from '../Assertions.js';
import { validate } from '../../util/validation.js';
import { selectMenuRolePredicate } 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.
*
@@ -34,8 +37,9 @@ export class RoleSelectMenuBuilder extends BaseSelectMenuBuilder<APIRoleSelectCo
* .setMinValues(1);
* ```
*/
public constructor(data?: Partial<APIRoleSelectComponent>) {
super({ ...data, type: ComponentType.RoleSelect });
public constructor(data: Partial<APIRoleSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.RoleSelect };
}
/**
@@ -45,7 +49,6 @@ 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(
@@ -65,7 +68,6 @@ 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,
@@ -74,4 +76,14 @@ 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,18 +1,30 @@
/* 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 { jsonOptionValidator, optionsLengthValidator, validateRequiredSelectMenuParameters } from '../Assertions.js';
import { resolveBuilder } from '../../util/resolveBuilder.js';
import { validate } from '../../util/validation.js';
import { selectMenuStringPredicate } 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 within this select menu.
* The options for this select menu.
*/
public readonly options: StringSelectMenuOptionBuilder[];
public get options(): readonly StringSelectMenuOptionBuilder[] {
return this.data.options;
}
/**
* Creates a new select menu from API data.
@@ -45,10 +57,13 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
* });
* ```
*/
public constructor(data?: Partial<APIStringSelectComponent>) {
const { options, ...initData } = data ?? {};
super({ ...initData, type: ComponentType.StringSelect });
this.options = options?.map((option: APISelectMenuOption) => new StringSelectMenuOptionBuilder(option)) ?? [];
public constructor({ options = [], ...data }: Partial<APIStringSelectComponent> = {}) {
super();
this.data = {
...structuredClone(data),
options: options.map((option) => new StringSelectMenuOptionBuilder(option)),
type: ComponentType.StringSelect,
};
}
/**
@@ -56,16 +71,18 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
*
* @param options - The options to add
*/
public addOptions(...options: RestOrArray<APISelectMenuOption | StringSelectMenuOptionBuilder>) {
public addOptions(
...options: RestOrArray<
| APISelectMenuOption
| StringSelectMenuOptionBuilder
| ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder)
>
) {
const normalizedOptions = normalizeArray(options);
optionsLengthValidator.parse(this.options.length + normalizedOptions.length);
this.options.push(
...normalizedOptions.map((normalizedOption) =>
normalizedOption instanceof StringSelectMenuOptionBuilder
? normalizedOption
: new StringSelectMenuOptionBuilder(jsonOptionValidator.parse(normalizedOption)),
),
);
const resolved = normalizedOptions.map((option) => resolveBuilder(option, StringSelectMenuOptionBuilder));
this.data.options.push(...resolved);
return this;
}
@@ -74,8 +91,14 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
*
* @param options - The options to set
*/
public setOptions(...options: RestOrArray<APISelectMenuOption | StringSelectMenuOptionBuilder>) {
return this.spliceOptions(0, this.options.length, ...options);
public setOptions(
...options: RestOrArray<
| APISelectMenuOption
| StringSelectMenuOptionBuilder
| ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder)
>
) {
return this.spliceOptions(0, this.options.length, ...normalizeArray(options));
}
/**
@@ -108,36 +131,33 @@ export class StringSelectMenuBuilder extends BaseSelectMenuBuilder<APIStringSele
public spliceOptions(
index: number,
deleteCount: number,
...options: RestOrArray<APISelectMenuOption | StringSelectMenuOptionBuilder>
...options: (
| APISelectMenuOption
| StringSelectMenuOptionBuilder
| ((builder: StringSelectMenuOptionBuilder) => StringSelectMenuOptionBuilder)
)[]
) {
const normalizedOptions = normalizeArray(options);
const resolved = options.map((option) => resolveBuilder(option, StringSelectMenuOptionBuilder));
const clone = [...this.options];
this.data.options ??= [];
this.data.options.splice(index, deleteCount, ...resolved);
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 BaseSelectMenuBuilder.toJSON}
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(): APIStringSelectComponent {
validateRequiredSelectMenuParameters(this.options, this.data.custom_id);
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)),
};
return {
...this.data,
options: this.options.map((option) => option.toJSON()),
} as APIStringSelectComponent;
validate(selectMenuStringPredicate, data, validationOverride);
return data as APIStringSelectComponent;
}
}

View File

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

View File

@@ -5,13 +5,16 @@ import {
SelectMenuDefaultValueType,
} from 'discord-api-types/v10';
import { type RestOrArray, normalizeArray } from '../../util/normalizeArray.js';
import { optionsLengthValidator } from '../Assertions.js';
import { validate } from '../../util/validation.js';
import { selectMenuUserPredicate } 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.
*
@@ -34,8 +37,9 @@ export class UserSelectMenuBuilder extends BaseSelectMenuBuilder<APIUserSelectCo
* .setMinValues(1);
* ```
*/
public constructor(data?: Partial<APIUserSelectComponent>) {
super({ ...data, type: ComponentType.UserSelect });
public constructor(data: Partial<APIUserSelectComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.UserSelect };
}
/**
@@ -45,9 +49,8 @@ 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 ??= [];
this.data.default_values.push(
...normalizedValues.map((id) => ({
id,
@@ -65,7 +68,6 @@ 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,
@@ -74,4 +76,14 @@ 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,45 +1,15 @@
import { s } from '@sapphire/shapeshift';
import { ComponentType, TextInputStyle } from 'discord-api-types/v10';
import { isValidationEnabled } from '../../util/validation.js';
import { customIdValidator, idValidator } from '../Assertions.js';
import { z } from 'zod';
import { customIdPredicate } from '../../Assertions.js';
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);
}
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(),
});

View File

@@ -1,26 +1,14 @@
import { isJSONEncodable, type Equatable, type JSONEncodable } from '@discordjs/util';
import { ComponentType, type TextInputStyle, type APITextInputComponent } from 'discord-api-types/v10';
import isEqual from 'fast-deep-equal';
import { customIdValidator } from '../Assertions.js';
import { validate } from '../../util/validation.js';
import { ComponentBuilder } from '../Component.js';
import {
maxLengthValidator,
minLengthValidator,
placeholderValidator,
requiredValidator,
valueValidator,
validateRequiredParameters,
labelValidator,
textInputStyleValidator,
} from './Assertions.js';
import { textInputPredicate } from './Assertions.js';
/**
* A builder that creates API-compatible JSON data for text inputs.
*/
export class TextInputBuilder
extends ComponentBuilder<APITextInputComponent>
implements Equatable<APITextInputComponent | JSONEncodable<APITextInputComponent>>
{
export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
private readonly data: Partial<APITextInputComponent>;
/**
* Creates a new text input from API data.
*
@@ -30,7 +18,7 @@ export class TextInputBuilder
* ```ts
* const textInput = new TextInputBuilder({
* custom_id: 'a cool text input',
* placeholder: 'Type something',
* label: 'Type something',
* style: TextInputStyle.Short,
* });
* ```
@@ -38,14 +26,15 @@ export class TextInputBuilder
* Creating a text input using setters and API data:
* ```ts
* const textInput = new TextInputBuilder({
* placeholder: 'Type something else',
* label: 'Type something else',
* })
* .setCustomId('woah')
* .setStyle(TextInputStyle.Paragraph);
* ```
*/
public constructor(data?: APITextInputComponent & { type?: ComponentType.TextInput }) {
super({ type: ComponentType.TextInput, ...data });
public constructor(data: Partial<APITextInputComponent> = {}) {
super();
this.data = { ...structuredClone(data), type: ComponentType.TextInput };
}
/**
@@ -54,7 +43,7 @@ export class TextInputBuilder
* @param customId - The custom id to use
*/
public setCustomId(customId: string) {
this.data.custom_id = customIdValidator.parse(customId);
this.data.custom_id = customId;
return this;
}
@@ -62,10 +51,9 @@ export class TextInputBuilder
* 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 = labelValidator.parse(label);
this.data.label = label;
return this;
}
@@ -75,7 +63,7 @@ export class TextInputBuilder
* @param style - The style to use
*/
public setStyle(style: TextInputStyle) {
this.data.style = textInputStyleValidator.parse(style);
this.data.style = style;
return this;
}
@@ -85,7 +73,15 @@ export class TextInputBuilder
* @param minLength - The minimum length of text for this text input
*/
public setMinLength(minLength: number) {
this.data.min_length = minLengthValidator.parse(minLength);
this.data.min_length = minLength;
return this;
}
/**
* Clears the minimum length of text for this text input.
*/
public clearMinLength() {
this.data.min_length = undefined;
return this;
}
@@ -95,7 +91,15 @@ export class TextInputBuilder
* @param maxLength - The maximum length of text for this text input
*/
public setMaxLength(maxLength: number) {
this.data.max_length = maxLengthValidator.parse(maxLength);
this.data.max_length = maxLength;
return this;
}
/**
* Clears the maximum length of text for this text input.
*/
public clearMaxLength() {
this.data.max_length = undefined;
return this;
}
@@ -105,7 +109,15 @@ export class TextInputBuilder
* @param placeholder - The placeholder to use
*/
public setPlaceholder(placeholder: string) {
this.data.placeholder = placeholderValidator.parse(placeholder);
this.data.placeholder = placeholder;
return this;
}
/**
* Clears the placeholder for this text input.
*/
public clearPlaceholder() {
this.data.placeholder = undefined;
return this;
}
@@ -115,7 +127,15 @@ export class TextInputBuilder
* @param value - The value to use
*/
public setValue(value: string) {
this.data.value = valueValidator.parse(value);
this.data.value = value;
return this;
}
/**
* Clears the value for this text input.
*/
public clearValue() {
this.data.value = undefined;
return this;
}
@@ -125,29 +145,17 @@ export class TextInputBuilder
* @param required - Whether this text input is required
*/
public setRequired(required = true) {
this.data.required = requiredValidator.parse(required);
this.data.required = required;
return this;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public toJSON(): APITextInputComponent {
validateRequiredParameters(this.data.custom_id, this.data.style);
public toJSON(validationOverride?: boolean): APITextInputComponent {
const clone = structuredClone(this.data);
validate(textInputPredicate, clone, validationOverride);
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);
return clone as APITextInputComponent;
}
}

View File

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

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

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

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

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

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

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

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

@@ -1,86 +0,0 @@
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,84 +1,71 @@
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/mixins/EmojiOrLabelButtonMixin.js';
export * from './components/button/Button.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/button/CustomIdButton.js';
export * from './components/button/LinkButton.js';
export * from './components/button/PremiumButton.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/fileUpload/FileUpload.js';
export * as FileUploadAssertions from './components/fileUpload/Assertions.js';
export * from './components/textInput/TextInput.js';
export * from './components/textInput/Assertions.js';
export * from './components/label/Label.js';
export * as LabelAssertions from './components/label/Assertions.js';
export * from './components/ActionRow.js';
export * from './components/Assertions.js';
export * from './components/Component.js';
export * from './components/Components.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/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 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/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 ContextMenuCommandAssertions from './interactions/contextMenuCommands/Assertions.js';
export * from './interactions/contextMenuCommands/ContextMenuCommandBuilder.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 * 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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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