Compare commits

...

58 Commits

Author SHA1 Message Date
Danial Raza
66fe4bbef6 fix(Application): name not nullable 2025-10-09 20:24:50 +02:00
Danial Raza
7dff2cd9b4 feat(applications): add Application structure 2025-10-09 19:45:24 +02:00
Almeida
7f29356950 fix: ending uncached polls (#11157) 2025-10-08 21:04:05 +00:00
Jiralite
230e746b31 ci(documentation): Skip unknown packages on old refs (#11154)
ci(documentation): handle old refs for unknown packages
2025-10-08 17:05:34 +00:00
Denis-Adrian Cristea
0c2975e3fd ci: implement workflow to publish dev versions (#11120)
* ci: implement workflow to publish dev versions

* ci: refactor into the other dev job

* fix: use dev tag

* chore: clarify

* fix: always use actions from main

* fix: conditionally

* chore: don't ask for meaningless perm
2025-10-08 11:08:39 +00:00
Denis-Adrian Cristea
fcf7f27fd7 ci(release): handling for create-discord-app (#11143)
* ci(release): handling for create-discord-app

* ci(deprecate): cda support

* ci: update our custom action to handle the renaming when invoked

* fix: don't double release on github

* chore: just in case

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-08 10:33:51 +00:00
Almeida
aac247cc18 feat: add {add,remove}GroupDMRecipient methods (#11135)
* feat: add `{add,delete}GroupDMRecipient methods`

* fix: requested changes

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-08 08:37:02 +00:00
Jiralite
a78dd012dc feat(guild): Support incident actions (#11131)
* feat(guild): add incident actions

* fix: add result

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-08 08:07:17 +00:00
Denis-Adrian Cristea
af923fee8e fix(WebSocketShard): bad error re-throw (#11151)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-07 09:59:40 +00:00
Jiralite
f1087c7e87 fix: Guide content path for CODEOWNERS (#11148)
fix: update guide path

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-07 09:55:46 +00:00
Almeida
7de5b4a349 refactor: remove guide route prefix (#11146)
* refactor: remove guide route prefix

* chore: implement backwards compat redirect

* Change guide redirect destination and permanence

Updated the redirect destination and permanence for the guide route.

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-07 09:50:53 +00:00
Jiralite
f109fc9b42 chore(guide): Update name of legacy path (#11149)
chore: update name of legacy path

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-07 09:49:30 +00:00
Pavel-Boyazov
b80dd8ce7f types(ClientEventTypes): fix messageDeleteBulk event arg (#11122)
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-10-07 08:44:26 +00:00
Almeida
88778df0e5 ci: bump actions/setup-node to v5 (#11134)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-06 22:47:49 +00:00
Almeida
985b525556 chore: bump dependencies (#11133)
* chore: bump dependencies

* chore: another bump
2025-10-06 22:42:31 +00:00
Jiralite
2c08b0f975 feat(thread)!: Add query to getting a thread member (#11136)
BREAKING CHANGE: Third parameter of `ThreadsAPI#getMember()` is now `query`, pushing `options` to the fourth.

* feat(thread): add query to getting a thread member

* Apply suggestion from @almeidx

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

* Apply suggestion from @almeidx

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

---------

Co-authored-by: Almeida <github@almeidx.dev>
2025-10-06 10:35:31 +01:00
Almeida
fbdec3d828 feat!: add escapeQuote and escapeBlockQuote (#11129)
BREAKING CHANGE: `escapeMarkdown` now escapes quotes and block quotes by default.

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-10-06 10:30:36 +01:00
Pavel-Boyazov
cf88ef91fd types(Webhook): specify message type (#11142)
* types(Webhook): specify message type

* test(Webhook): update types
2025-10-05 20:42:15 +00:00
Almeida
dee79efbaf fix: sidebar tab icon (#11141) 2025-10-05 19:46:59 +02:00
Vlad Frangu
05224e78ec fix(builders): correct assertion for select menu string min/max values (#11139)
* fix(builders): correct assertion for select menu string min/max values

* fix: actually fix the logic

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-05 15:28:34 +00:00
Almeida
612c49b546 fix: adjust types for typescript upgrade (#11132)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-05 12:43:49 +00:00
Jiralite
ffbb7b6936 feat: Add gateway endpoints (#11130)
feat: add gateway

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-04 16:35:21 +00:00
Denis-Adrian Cristea
d251e065cd feat(RedisBroker): ability to explicitly tell the library to pick a random group (#11002)
feat(RedisBroker): randomly pick group via symbol
2025-10-04 14:20:27 +00:00
Denis-Adrian Cristea
cf89260c98 feat(RedisBroker): poll for unacked events (#11004)
Co-authored-by: Noel <buechler.noel@outlook.com>
2025-10-04 10:59:48 +02:00
Denis-Adrian Cristea
2c750a4e00 refactor!: make RedisBroker require consumer name (#11001)
* refactor(RedisBroker): require consumer name

* chore: spelling

Co-authored-by: Noel <buechler.noel@outlook.com>

---------

Co-authored-by: Noel <buechler.noel@outlook.com>
2025-10-04 10:51:43 +02:00
ckohen
6431cea24b ci(release): use main action always (#11125)
* ci(release): use main action always

* ci(release): use more descriptive names

* ci: review fixes

* ci(release): fixes from testing

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-10-04 08:50:30 +00:00
Naiyar
f510b5ffab docs(ModalComponentResolver): correct getTextInputValue return type (#11128) 2025-10-01 18:09:54 +00:00
Almeida
1c5674d9b2 fix(ThreadMemberFlagsBitField): use ThreadMemberFlags enum in Flags (#11118)
feat(ThreadMemberFlagsBitField): use `ThreadMemberFlags` enum in `Flags`
2025-09-27 15:36:51 +00:00
Almeida
5efde1162f docs: use LocalizationMap where applicable (#11117) 2025-09-27 14:48:06 +00:00
Jiralite
9201243f32 docs(APITypes): Add APISelectMenuDefaultValue (#11111)
docs: add `APISelectMenuDefaultValue`

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-09-26 16:40:22 +00:00
Jiralite
0d76f1149f fix: Add idPredicate (#11109)
* fix: `idPredicate`

* fix: add test

* test: add negative test
2025-09-26 16:06:22 +00:00
Naiyar
b3705df547 fix: use in operator when resolving modal component (#11115) 2025-09-26 09:10:51 +00:00
Jiralite
2d740d5279 fix(GuildMember): Use editMe() conditionally for setNickname() helper (#11113)
fix(GuildMember): use `editMe()` conditionally

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-09-24 20:38:47 +00:00
Jiralite
beed098bf2 fix: Add type to some predicates (#11110)
fix: add `type`

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-09-22 09:10:20 +00:00
Jiralite
7886d9b098 feat(GuildMemberManager)!: Add new modify self fields (#11089)
BREAKING CHANGE: `GuildMemberManager#edit()` no longer handles the /@me route for editing the current application's guild member. This has split out into `GuildMemberManager#editMe()`
2025-09-21 22:51:57 +01:00
Naiyar
5247afe983 feat!: text display and other select menus for modal (#11097)
BREAKING CHANGE: `ModalSubmitFields` is no longer exported
BREAKING CHANGE: `ModalSubmitInteraction#fields` is removed
BREAKING CHANGE: `ModalSubmitInteraction#components` is now `ModalOptionResolver`
BREAKING CHANGE: `DjsErrorCodes#ModalSubmitInteractionFieldNotFound` & `DjsErrorCodes#ModalSubmitInteractionFieldType` are now renamed to `DjsErrorCodes#ModalSubmitInteractionComponentNotFound` & `DjsErrorCodes#ModalSubmitInteractionComponentType` respectively

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-09-21 22:18:18 +01:00
Jiralite
b66f52f9aa feat!: More label components and text display in modal (#11078)
BREAKING CHANGE: Modals only have adding (no setting) and splicing has been replaced with a generalised splice method to support all components.
2025-09-14 09:09:56 +01:00
Almeida
126529f460 fix: overflow on package home (#11101) 2025-09-13 16:21:20 +00:00
Jiralite
90aac127fa fix(users): Correct type for editing current guild member (#11099)
* fix(user): use correct type

* fix: imports

* fix: imports

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-09-10 21:11:52 +00:00
Naiyar
4ec03ae517 feat!: label component and selects in modal (#11081)
BREAKING CHANGE: TextInputComponentData no longer accepts label
BREAKING CHANGE: ActionRow and ActionRowData no longer accept TextInput
BREAKING CHANGE: `ModalSubmitInteraction#transformComponent` is now private and no longer exposed publicly

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-09-10 21:34:57 +01:00
ckohen
f1bcff46b6 feat(rest): callbacks for timeout and retry backoff (#11067)
* feat(rest): callbacks for timeout and retry backoff

* test: add tests for callback utils

* test: fix typo

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

* fix(retryBackoff): efficient math

* docs: minor tweaks

* docs: captalisation

---------

Co-authored-by: Qjuh <76154676+Qjuh@users.noreply.github.com>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-09-10 09:23:28 +00:00
Jiralite
5d5a6945e4 build: Update discord-api-types to 0.38.23 (#11095)
build: update
2025-09-10 08:30:00 +00:00
Almeida
352c9819b6 feat: send voice messages (#10462)
* feat: send voice messages

* fix: title reference

Co-authored-by: ckohen <chaikohen@gmail.com>

* docs: clarify voice message attachment properties in documentation

---------

Co-authored-by: ckohen <chaikohen@gmail.com>
2025-09-06 11:12:24 +00:00
Jiralite
f7c77a73de feat(builders)!: Support select in modals (#11034)
BREAKING CHANGE: Text inputs no longer accept a label.
BREAKING CHANGE: Modals now only set labels instead of action rows.
2025-09-05 17:56:14 +01:00
VAKiliner
ddf9f818e8 fix: Ensure discriminator detection respects webhooks too (#11062)
* Replace discriminator === '0' to Number(discriminator)

* Fix

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

* chore: fmt

* perf: no array

---------

Co-authored-by: almeidx <github@almeidx.dev>
Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-09-05 07:39:35 +00:00
Almeida
8ca279e0c3 refactor: update deno template and loader logic (#11060)
* refactor: update deno template and loader logic

* yeet

---------

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-09-04 12:36:26 +00:00
Almeida
5a656b849f refactor(PollAnswer)!: remove fetchVoters (#11059)
BREAKING CHANGE: The `PollAnswer#fetchVoters` method has been removed. Use `PollAnswer#voters#fetch` instead.

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
2025-09-02 17:51:24 +01:00
Jiralite
bb67f3c154 feat: Guest invites (#11055)
* feat: guest invites

* types: add types

* docs: add `InviteFlags`

* docs: grammar

Co-authored-by: Sören Stabenow <71461991+thehairy@users.noreply.github.com>

---------

Co-authored-by: Sören Stabenow <71461991+thehairy@users.noreply.github.com>
2025-09-01 22:14:24 +00:00
Almeida
90813b33aa build: use the same timestamp for release versions (#11069) 2025-08-31 20:43:22 +00:00
Almeida
cc43dadcae chore: bump dependencies (#11051)
* chore: bump dependencies

* build: bump discord-api-types to 0.38.22

* fix: fix builders

---------

Co-authored-by: Jiralite <33201955+Jiralite@users.noreply.github.com>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-08-29 12:01:54 +00:00
Jiralite
cde757b7cb ci(pr-triage): Make validate title run always (#11056)
ci(pr-triage): fix bad name merge

Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-08-29 11:57:22 +00:00
ckohen
368edeaaff fix(rest): emit warning on HTTP 401 with non-zero code (#11066)
* fix(rest): emit warning on HTTP 401 with non-zero code

* fix: typos

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

* fix: explicit globalThis

---------

Co-authored-by: Almeida <github@almeidx.dev>
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-08-29 10:33:17 +00:00
Jiralite
d548a5911e fix(Guild)!: Remove setting owner (#11068)
BREAKING CHANGE: Setting the owner of a guild is removed.
2025-08-29 11:12:14 +01:00
Almeida
50018979ed feat: add email and phoneNumber formatters (#11050)
Co-authored-by: kodiakhq[bot] <49736102+kodiakhq[bot]@users.noreply.github.com>
2025-08-26 21:26:05 +00:00
Souji
55ab46dbc3 fix(GuildChannel): account for everyone base permissions (#11053)
When calculating permissions after overwrites, the base permission of the at-everyone role need to be accounted for.
Role#permissions is not sufficient, as it only describes base permissions of the role itself.

fixes #11052
2025-08-22 11:03:33 +00:00
Vlad Frangu
4b6060dcd8 feat: bump discord.js in create-discord-bot/app (#11048)
* feat: bump discord.js in create-discord-bot/app

* chore: forgot core
2025-08-21 06:58:35 +00:00
J4C0B3Y
b1d96e251f refactor(ActionsManager): register actions without using class name (#11047) 2025-08-19 08:51:52 +00:00
ckohen
7710decf6a ci(release): use full commit hash (#11046) 2025-08-18 07:57:24 +00:00
172 changed files with 9430 additions and 7060 deletions

2
.github/CODEOWNERS vendored
View File

@@ -7,7 +7,7 @@ package.json @discordjs/core
pnpm-lock.yaml @discordjs/core
/apps/guide/ @discordjs/website @discordjs/guide
/apps/guide/src/content/ @discordjs/guide
/apps/guide/content/ @discordjs/guide
/apps/website/ @discordjs/website
/packages/actions/ @discordjs/actions

View File

@@ -17,9 +17,10 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
- name: Install dependencies
uses: ./packages/actions/src/pnpmCache

View File

@@ -11,6 +11,7 @@ on:
- '@discordjs/builders'
- '@discordjs/collection'
- '@discordjs/core'
- 'create-discord-app'
- 'create-discord-bot'
- '@discordjs/formatters'
- 'discord.js'
@@ -38,9 +39,10 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
- name: Install dependencies
uses: ./packages/actions/src/pnpmCache

View File

@@ -41,9 +41,10 @@ jobs:
ref: ${{ inputs.ref || '' }}
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
- name: Install dependencies
uses: ./packages/actions/src/pnpmCache
@@ -88,6 +89,11 @@ jobs:
run: |
declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "structures" "util" "voice" "ws")
for PACKAGE in "${PACKAGES[@]}"; do
if [ ! -d "packages/${PACKAGE}" ]; then
echo "::notice::${PACKAGE} does not exist on this ref. Skipping..."
continue
fi
cd "packages/${PACKAGE}"
sed -i 's!https://github.com/discordjs/discord.js/tree/main!https://github.com/discordjs/discord.js/tree/${{ inputs.ref }}!' api-extractor.json
../../main/packages/api-extractor/bin/api-extractor run --local --minify
@@ -221,6 +227,11 @@ jobs:
run: |
declare -a PACKAGES=("brokers" "builders" "collection" "core" "discord.js" "formatters" "next" "proxy" "rest" "structures" "util" "voice" "ws")
for PACKAGE in "${PACKAGES[@]}"; do
if [ ! -d "packages/${PACKAGE}" ]; then
echo "::notice::${PACKAGE} does not exist on this ref. Skipping..."
continue
fi
if [[ "${PACKAGE}" == "discord.js" ]]; then
mkdir -p "out/${PACKAGE}"
mv "packages/${PACKAGE}/docs/docs.json" "out/${PACKAGE}/${GITHUB_REF_NAME}.json"
@@ -253,9 +264,10 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
- name: Install dependencies
uses: ./packages/actions/src/pnpmCache

View File

@@ -19,7 +19,6 @@ jobs:
sync-labels: true
validate-title:
name: Validate title
if: github.event.action != 'synchronize'
runs-on: ubuntu-latest
steps:
- name: Validate pull request title

View File

@@ -13,9 +13,10 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
- name: Install dependencies
uses: ./packages/actions/src/pnpmCache

View File

@@ -4,6 +4,14 @@ on:
- cron: '0 */12 * * *'
workflow_dispatch:
inputs:
pull:
description: 'The pull number to check out'
required: false
default: 'main'
tag:
description: 'The tag to use, generally a feature name'
required: false
type: string
dry_run:
description: 'Perform a dry run that skips publishing and outputs logs indicating what would have happened'
type: boolean
@@ -19,15 +27,33 @@ jobs:
TURBO_TEAM: ${{ secrets.TURBO_TEAM }}
if: github.repository_owner == 'discordjs'
steps:
- uses: actions/create-github-app-token@v2
id: app-token
with:
app-id: ${{ vars.DISCORDJS_APP_ID }}
private-key: ${{ secrets.DISCORDJS_APP_KEY_RELEASE }}
- name: Decide ref
id: ref
run: |
if [ -n "${{ github.event.inputs.pull }}" ]; then
echo "ref=refs/pull/${{ github.event.inputs.pull }}/head" >> $GITHUB_OUTPUT
else
echo "ref=refs/heads/main" >> $GITHUB_OUTPUT
fi
- name: Checkout repository
uses: actions/checkout@v5
with:
fetch-depth: 0
token: ${{ steps.app-token.outputs.token }}
ref: ${{ steps.ref.outputs.ref }}
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
registry-url: https://registry.npmjs.org/
- name: Install dependencies
@@ -36,12 +62,42 @@ jobs:
- name: Build dependencies
run: pnpm run build
- name: Publish packages
- name: Checkout main repository (non-main ref)
if: ${{ steps.ref.outputs.ref != 'refs/heads/main' }}
uses: actions/checkout@v5
with:
path: 'main'
- name: Install action deps (non-main ref)
if: ${{ steps.ref.outputs.ref != 'refs/heads/main' }}
shell: bash
working-directory: ./main
env:
COREPACK_ENABLE_STRICT: 0
run: |
pnpm self-update 10
pnpm install --filter @discordjs/actions --frozen-lockfile --prefer-offline --loglevel error
- name: Publish packages (non-main ref)
if: ${{ steps.ref.outputs.ref != 'refs/heads/main' }}
uses: ./main/packages/actions/src/releasePackages
with:
exclude: '@discordjs/docgen'
dry: ${{ inputs.dry_run }}
dev: true
tag: ${{ inputs.tag || 'dev' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Publish packages (main ref)
if: ${{ steps.ref.outputs.ref == 'refs/heads/main' }}
uses: ./packages/actions/src/releasePackages
with:
exclude: '@discordjs/docgen'
dry: ${{ inputs.dry_run }}
dev: true
tag: ${{ inputs.tag || 'dev' }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -10,9 +10,10 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
- name: Install dependencies
uses: ./packages/actions/src/pnpmCache

View File

@@ -17,9 +17,10 @@ jobs:
uses: actions/checkout@v5
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
registry-url: https://registry.npmjs.org/
- name: Install dependencies

View File

@@ -61,9 +61,10 @@ jobs:
ref: ${{ inputs.ref || '' }}
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
registry-url: https://registry.npmjs.org/
- name: Install dependencies
@@ -72,7 +73,35 @@ jobs:
- name: Build dependencies
run: pnpm run build
- name: Release packages
- name: Checkout main repository
if: ${{ inputs.ref && inputs.ref != 'main' }}
uses: actions/checkout@v5
with:
path: 'main'
- name: Install action deps (non-main ref only)
if: ${{ inputs.ref && inputs.ref != 'main' }}
shell: bash
working-directory: ./main
env:
COREPACK_ENABLE_STRICT: 0
run: |
pnpm self-update 10
pnpm install --filter @discordjs/actions --frozen-lockfile --prefer-offline --loglevel error
- name: Release packages (non-main ref)
if: ${{ inputs.ref && inputs.ref != 'main' }}
uses: ./main/packages/actions/src/releasePackages
with:
package: ${{ inputs.package }}
exclude: ${{ inputs.exclude }}
dry: ${{ inputs.dry_run }}
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
- name: Release packages (main ref)
if: ${{ !inputs.ref || inputs.ref == 'main' }}
uses: ./packages/actions/src/releasePackages
with:
package: ${{ inputs.package }}

View File

@@ -20,9 +20,10 @@ jobs:
fetch-depth: 0
- name: Install Node.js v22
uses: actions/setup-node@v4
uses: actions/setup-node@v5
with:
node-version: 22
package-manager-cache: false
- name: Install dependencies
uses: ./packages/actions/src/pnpmCache

View File

@@ -23,3 +23,4 @@ pids
.tmp
.vscode
.vercel
next-env.d.ts

View File

@@ -1,9 +1,9 @@
{
"pages": [
"[MessageCircleQuestion][FAQ](/guide/legacy/popular-topics/faq)",
"[ArrowDownToLine][Updating to v14](/guide/legacy/additional-info/changes-in-v14)",
"[MessageCircleQuestion][FAQ](/legacy/popular-topics/faq)",
"[ArrowDownToLine][Updating to v14](/legacy/additional-info/changes-in-v14)",
"[LibraryBig][Documentation](https://discord.js.org/docs)",
"[Info][Introduction](/guide/legacy)",
"[Info][Introduction](/legacy)",
"---Setup---",
"preparations",
"---Your App---",
@@ -25,8 +25,8 @@
"...oauth2",
"sharding"
],
"title": "Legacy Guide",
"description": "The legacy discord.js guide",
"title": "discord.js",
"description": "The discord.js guide",
"icon": "Book",
"root": true
}

View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -25,13 +25,13 @@ export default withMDX({
},
experimental: {
ppr: true,
reactCompiler: true,
useCache: true,
dynamicOnHover: true,
},
eslint: {
ignoreDuringBuilds: true,
},
reactCompiler: true,
typescript: {
ignoreBuildErrors: true,
},

View File

@@ -14,7 +14,7 @@
"lint": "pnpm run build:check && prettier --check . && cross-env TIMING=1 eslint --format=pretty src ",
"format": "pnpm run build:check && prettier --write . && cross-env TIMING=1 eslint --fix --format=pretty src ",
"fmt": "pnpm run format",
"postinstall": "fumadocs-mdx"
"postinstall": "next typegen && fumadocs-mdx"
},
"type": "module",
"directories": {
@@ -48,61 +48,61 @@
"@vercel/analytics": "^1.5.0",
"cmdk": "^1.1.1",
"cva": "1.0.0-beta.3",
"fumadocs-core": "^15.6.3",
"fumadocs-mdx": "^11.6.10",
"fumadocs-twoslash": "^3.1.4",
"fumadocs-ui": "^15.6.3",
"geist": "^1.4.2",
"immer": "^10.1.1",
"jotai": "^2.12.5",
"fumadocs-core": "^15.8.4",
"fumadocs-mdx": "^12.0.3",
"fumadocs-twoslash": "^3.1.8",
"fumadocs-ui": "^15.8.4",
"geist": "^1.5.1",
"immer": "^10.1.3",
"jotai": "^2.15.0",
"jotai-immer": "^0.4.1",
"lucide-react": "^0.525.0",
"mermaid": "^11.8.1",
"motion": "^12.23.3",
"next": "15.4.0-canary.11",
"next-mdx-remote-client": "^2.1.2",
"lucide-react": "^0.545.0",
"mermaid": "^11.12.0",
"motion": "^12.23.22",
"next": "15.6.0-canary.45",
"next-mdx-remote-client": "^2.1.6",
"next-themes": "^0.4.6",
"nuqs": "^2.4.3",
"react": "^19.1.0",
"react-aria": "^3.41.1",
"react-aria-components": "^1.10.1",
"react-dom": "^19.1.0",
"nuqs": "^2.7.1",
"react": "^19.2.0",
"react-aria": "^3.44.0",
"react-aria-components": "^1.13.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"sharp": "^0.34.3",
"sharp": "^0.34.4",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.5",
"twoslash": "^0.3.2",
"tw-animate-css": "^1.4.0",
"twoslash": "^0.3.4",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@next/env": "^15.3.5",
"@shikijs/rehype": "^3.7.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@next/env": "^15.5.4",
"@shikijs/rehype": "^3.13.0",
"@tailwindcss/postcss": "^4.1.14",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.14",
"@types/mdx": "^2.0.13",
"@types/node": "^22.16.3",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@types/node": "^22.18.8",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"autoprefixer": "^10.4.21",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"cpy-cli": "^5.0.0",
"cross-env": "^7.0.3",
"eslint": "^9.30.1",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"cpy-cli": "^6.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"git-describe": "^4.1.1",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"remark-gfm": "^4.0.1",
"remark-rehype": "^11.1.2",
"shiki": "^3.7.0",
"tailwindcss": "^4.1.11",
"tailwindcss-react-aria-components": "^2.0.0",
"turbo": "^2.5.4",
"typescript": "^5.8.3",
"vercel": "^44.4.1"
"shiki": "^3.13.0",
"tailwindcss": "^4.1.14",
"tailwindcss-react-aria-components": "^2.0.1",
"turbo": "^2.5.8",
"typescript": "^5.9.3",
"vercel": "^48.2.1"
},
"engines": {
"node": ">=22.12.0"

View File

@@ -8,7 +8,7 @@ export async function generateStaticParams() {
return source.generateParams();
}
export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }) {
export async function generateMetadata(props: { params: Promise<{ slug?: string[] }> }): Promise<Metadata> {
const params = await props.params;
const page = source.getPage(params.slug);
@@ -16,7 +16,7 @@ export async function generateMetadata(props: { params: Promise<{ slug?: string[
notFound();
}
const image = ['/docs-og', ...(params.slug ?? []), 'image.png'].join('/');
const image = ['/og', ...(params.slug ?? []), 'image.png'].join('/');
return {
title: page.data.title,
description: page.data.description,
@@ -27,7 +27,7 @@ export async function generateMetadata(props: { params: Promise<{ slug?: string[
card: 'summary_large_image',
images: image,
},
} satisfies Metadata;
};
}
export default async function Page(props: { readonly params: Promise<{ slug?: string[] }> }) {

View File

@@ -1,41 +0,0 @@
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import type { ReactNode } from 'react';
import { baseOptions } from '@/app/layout.config';
import { source } from '@/lib/source';
export default function Layout({ children }: { readonly children: ReactNode }) {
return (
<DocsLayout
sidebar={{
tabs: {
transform(option, node) {
const meta = source.getNodeMeta(node);
if (!meta || !node.icon) return option;
// category selection color based on path src/styles/base.css
const color = `var(--${meta.file.path.split('/')[0]}-color, var(--color-fd-foreground))`;
return {
...option,
icon: (
<div
className="rounded-lg border p-1.5 shadow-lg md:mb-auto md:rounded-md md:p-1 [&_svg]:size-6 md:[&_svg]:size-5"
style={{
color,
backgroundColor: `color-mix(in oklab, ${color} 10%, transparent)`,
}}
>
{node.icon}
</div>
),
};
},
},
}}
tree={source.pageTree}
{...baseOptions}
>
{children}
</DocsLayout>
);
}

View File

@@ -1,11 +1,14 @@
import { Analytics } from '@vercel/analytics/react';
import { DocsLayout } from 'fumadocs-ui/layouts/docs';
import { RootProvider } from 'fumadocs-ui/provider';
import { GeistMono } from 'geist/font/mono';
import { GeistSans } from 'geist/font/sans';
import type { Metadata, Viewport } from 'next';
import type { PropsWithChildren } from 'react';
import type { CSSProperties, PropsWithChildren } from 'react';
import { Body } from '@/app/layout.client';
import { source } from '@/lib/source';
import { ENV } from '@/util/env';
import { baseOptions } from './layout.config';
import '@/styles/base.css';
@@ -18,7 +21,7 @@ export const viewport: Viewport = {
};
export const metadata: Metadata = {
metadataBase: new URL(ENV.IS_LOCAL_DEV ? `http://localhost:${ENV.PORT}` : 'https://next.discordjs.guide'),
metadataBase: new URL(ENV.IS_LOCAL_DEV ? `http://localhost:${ENV.PORT}` : 'https://discordjs.guide'),
title: {
template: '%s | discord.js',
default: 'discord.js',
@@ -74,7 +77,41 @@ export default async function RootLayout({ children }: PropsWithChildren) {
return (
<html className={`${GeistSans.variable} ${GeistMono.variable} antialiased`} lang="en" suppressHydrationWarning>
<Body>
<RootProvider>{children}</RootProvider>
<RootProvider>
<DocsLayout
sidebar={{
tabs: {
transform(option, node) {
const meta = source.getNodeMeta(node);
if (!meta || !node.icon) return option;
// category selection color based on path src/styles/base.css
const color = `var(--${meta.path.split('/')[0]}-color, var(--color-fd-foreground))`;
return {
...option,
icon: (
<div
className="size-full rounded-lg text-(--tab-color) max-md:border max-md:bg-(--tab-color)/10 max-md:p-1.5 [&_svg]:size-full"
style={
{
'--tab-color': color,
} as CSSProperties
}
>
{node.icon}
</div>
),
};
},
},
}}
tree={source.pageTree}
{...baseOptions}
>
{children}
</DocsLayout>
</RootProvider>
<Analytics />
</Body>
</html>

View File

@@ -12,7 +12,7 @@ export function generateStaticParams() {
export async function GET(_req: Request, { params }: { params: Promise<{ slug: string[] }> }) {
const { slug } = await params;
const page = source.getPage(slug.slice(0, -1));
// const fontData = await fetch(new URL('../../../assets/Geist-Regular.ttf', import.meta.url), {
// const fontData = await fetch(new URL('../../assets/Geist-Regular.ttf', import.meta.url), {
// next: { revalidate: 604_800 },
// }).then(async (res) => res.arrayBuffer());

View File

@@ -1,5 +0,0 @@
import { redirect } from 'next/navigation';
export default async function Page() {
redirect('/guide/legacy');
}

View File

@@ -15,6 +15,6 @@ export const source = loader({
return undefined;
},
baseUrl: '/guide/',
baseUrl: '/',
source: docs.toFumadocsSource(),
});

View File

@@ -0,0 +1,16 @@
import { NextResponse, type NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// TODO: Remove this eventually
if (request.nextUrl.pathname.startsWith('/guide/')) {
const newUrl = request.nextUrl.clone();
newUrl.pathname = newUrl.pathname.replace('/guide/', '/');
return NextResponse.redirect(new URL(newUrl.pathname, request.url));
}
return NextResponse.redirect(new URL('/legacy', request.url));
}
export const config = {
matcher: ['/', '/guide/:path*'],
};

View File

@@ -25,6 +25,7 @@ src/styles/unocss.css
.tmp
.vscode
lighthouse-results
next-env.d.ts
.vercel

View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -21,12 +21,12 @@ export default {
},
experimental: {
ppr: true,
reactCompiler: true,
dynamicOnHover: true,
},
eslint: {
ignoreDuringBuilds: true,
},
reactCompiler: true,
typescript: {
ignoreBuildErrors: true,
},
@@ -39,8 +39,8 @@ export default {
},
{
source: '/guide/:path*',
destination: 'https://next.discordjs.guide/guide/:path*',
permanent: true,
destination: 'https://discordjs.guide/:path*',
permanent: false,
},
];
},

View File

@@ -16,7 +16,8 @@
"dev": "next dev --turbopack",
"lint": "pnpm run build:check && prettier --check . && cross-env TIMING=1 eslint --format=pretty src ",
"format": "pnpm run build:check && prettier --write . && cross-env TIMING=1 eslint --fix --format=pretty src ",
"fmt": "pnpm run format"
"fmt": "pnpm run format",
"postinstall": "next typegen"
},
"type": "module",
"directories": {
@@ -46,66 +47,66 @@
"homepage": "https://discord.js.org",
"funding": "https://github.com/discordjs/discord.js?sponsor",
"dependencies": {
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-collapsible": "^1.1.12",
"@react-icons/all-files": "^4.1.0",
"@tanstack/react-query": "^5.83.0",
"@tanstack/react-query": "^5.90.2",
"@vercel/analytics": "^1.5.0",
"@vercel/edge-config": "^1.4.0",
"@vercel/postgres": "^0.10.0",
"cloudflare": "^4.4.1",
"cloudflare": "^5.2.0",
"cmdk": "^1.1.1",
"cva": "1.0.0-beta.3",
"geist": "^1.4.2",
"immer": "^10.1.1",
"jotai": "^2.12.5",
"geist": "^1.5.1",
"immer": "^10.1.3",
"jotai": "^2.15.0",
"jotai-immer": "^0.4.1",
"lucide-react": "^0.525.0",
"lucide-react": "^0.545.0",
"meilisearch": "^0.50.0",
"motion": "^12.23.3",
"next": "15.4.0-canary.35",
"next-mdx-remote-client": "^2.1.2",
"motion": "^12.23.22",
"next": "15.6.0-canary.45",
"next-mdx-remote-client": "^2.1.6",
"next-themes": "^0.4.6",
"nuqs": "^2.4.3",
"overlayscrollbars": "^2.11.4",
"nuqs": "^2.7.1",
"overlayscrollbars": "^2.12.0",
"overlayscrollbars-react": "^0.5.6",
"react": "^19.1.0",
"react-aria": "^3.41.1",
"react-aria-components": "^1.10.1",
"react-dom": "^19.1.0",
"react": "^19.2.0",
"react-aria": "^3.44.0",
"react-aria-components": "^1.13.0",
"react-dom": "^19.2.0",
"react-error-boundary": "^6.0.0",
"sharp": "^0.34.3",
"sharp": "^0.34.4",
"tailwind-merge": "^3.3.1",
"tw-animate-css": "^1.3.5",
"tw-animate-css": "^1.4.0",
"usehooks-ts": "^3.1.1"
},
"devDependencies": {
"@next/env": "^15.3.5",
"@shikijs/rehype": "^3.7.0",
"@tailwindcss/postcss": "^4.1.11",
"@tailwindcss/typography": "^0.5.16",
"@tailwindcss/vite": "^4.1.11",
"@types/node": "^22.16.3",
"@types/react": "^19.1.8",
"@types/react-dom": "^19.1.6",
"@next/env": "^15.5.4",
"@shikijs/rehype": "^3.13.0",
"@tailwindcss/postcss": "^4.1.14",
"@tailwindcss/typography": "^0.5.19",
"@tailwindcss/vite": "^4.1.14",
"@types/node": "^22.18.8",
"@types/react": "^19.2.0",
"@types/react-dom": "^19.2.0",
"autoprefixer": "^10.4.21",
"babel-plugin-react-compiler": "19.1.0-rc.2",
"cpy-cli": "^5.0.0",
"cross-env": "^7.0.3",
"eslint": "^9.30.1",
"babel-plugin-react-compiler": "19.1.0-rc.3",
"cpy-cli": "^6.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"git-describe": "^4.1.1",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"prettier-plugin-tailwindcss": "^0.6.14",
"remark-gfm": "^4.0.1",
"remark-rehype": "^11.1.2",
"shiki": "^3.7.0",
"tailwindcss": "^4.1.11",
"tailwindcss-react-aria-components": "^2.0.0",
"turbo": "^2.5.4",
"typescript": "^5.8.3",
"vercel": "^44.4.1"
"shiki": "^3.13.0",
"tailwindcss": "^4.1.14",
"tailwindcss-react-aria-components": "^2.0.1",
"turbo": "^2.5.8",
"typescript": "^5.9.3",
"vercel": "^48.2.1"
},
"engines": {
"node": ">=22.12.0"

View File

@@ -61,7 +61,7 @@ export default async function Page({
const fileContent = await readFile(join(process.cwd(), `src/assets/readme/${packageName}/home-README.md`), 'utf8');
return (
<div className="prose prose-neutral dark:prose-invert prose-a:[&>img]:inline-block prose-a:[&>img]:m-0 prose-a:[&>img[height='44']]:h-11 prose-p:my-2 prose-pre:py-3 prose-pre:rounded-sm prose-pre:px-0 prose-pre:border prose-pre:border-[#d4d4d4] dark:prose-pre:border-[#404040] prose-code:font-normal prose-a:text-[#5865F2] prose-a:no-underline prose-a:hover:text-[#3d48c3] dark:prose-a:hover:text-[#7782fa] mx-auto max-w-screen-xl px-6 py-6 [&_code_span:last-of-type:empty]:hidden [&_div[align='center']_p_a+a]:ml-2">
<div className="prose prose-neutral dark:prose-invert prose-a:[&>img]:inline-block prose-a:[&>img]:m-0 prose-a:[&>img[height='44']]:h-11 prose-p:my-2 prose-pre:py-3 prose-pre:rounded-sm prose-pre:px-0 prose-pre:border prose-pre:border-[#d4d4d4] dark:prose-pre:border-[#404040] prose-code:font-normal prose-a:text-[#5865F2] prose-a:no-underline prose-a:hover:text-[#3d48c3] dark:prose-a:hover:text-[#7782fa] mx-auto max-w-screen-xl px-6 py-6 break-words [&_code_span:last-of-type:empty]:hidden [&_div[align='center']_p_a+a]:ml-2">
<MDXRemote
options={{
mdxOptions: {

View File

@@ -51,33 +51,33 @@
"homepage": "https://discord.js.org",
"funding": "https://github.com/discordjs/discord.js?sponsor",
"devDependencies": {
"@commitlint/cli": "^19.8.1",
"@commitlint/config-angular": "^19.8.1",
"@commitlint/cli": "^20.1.0",
"@commitlint/config-angular": "^20.0.0",
"@favware/cliff-jumper": "^4.1.0",
"@favware/npm-deprecate": "^2.0.0",
"@types/lodash.merge": "^4.6.9",
"@unocss/eslint-plugin": "^66.3.3",
"@unocss/eslint-plugin": "^66.5.2",
"@vitest/coverage-v8": "^3.2.4",
"conventional-changelog-cli": "^5.0.0",
"eslint": "^9.30.1",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-import-resolver-typescript": "^4.4.4",
"eslint-plugin-react-compiler": "19.1.0-rc.2",
"husky": "^9.1.7",
"is-ci": "^4.1.0",
"lint-staged": "^16.1.2",
"lint-staged": "^16.2.3",
"lodash.merge": "^4.6.2",
"prettier": "^3.6.2",
"tsup": "^8.5.0",
"turbo": "^2.5.4",
"typescript": "~5.8.3",
"typescript-eslint": "^8.36.0",
"unocss": "^66.3.3",
"vercel": "^44.4.1",
"turbo": "^2.5.8",
"typescript": "~5.9.3",
"typescript-eslint": "^8.46.0",
"unocss": "^66.5.2",
"vercel": "^48.2.1",
"vitest": "^3.2.4"
},
"engines": {
"node": ">=22.12.0"
},
"packageManager": "pnpm@10.12.4"
"packageManager": "pnpm@10.18.1"
}

View File

@@ -44,33 +44,33 @@
"@actions/core": "^1.11.1",
"@actions/github": "^6.0.1",
"@actions/glob": "^0.5.0",
"@aws-sdk/client-s3": "^3.844.0",
"@aws-sdk/client-s3": "^3.901.0",
"@discordjs/scripts": "workspace:^",
"@vercel/blob": "^1.1.1",
"@vercel/blob": "^2.0.0",
"@vercel/postgres": "^0.10.0",
"cloudflare": "^4.4.1",
"commander": "^14.0.0",
"cloudflare": "^5.2.0",
"commander": "^14.0.1",
"meilisearch": "^0.38.0",
"p-limit": "^6.2.0",
"p-queue": "^8.1.0",
"p-limit": "^7.1.1",
"p-queue": "^9.0.0",
"tslib": "^2.8.1",
"undici": "7.11.0"
"undici": "7.16.0"
},
"devDependencies": {
"@npm/types": "^2.1.0",
"@types/bun": "^1.2.19",
"@types/node": "^22.16.3",
"@types/bun": "^1.2.23",
"@types/node": "^22.18.8",
"@vitest/coverage-v8": "^3.2.4",
"cross-env": "^7.0.3",
"eslint": "^9.30.1",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"terser": "^5.43.1",
"terser": "^5.44.0",
"tsup": "^8.5.0",
"turbo": "^2.5.4",
"typescript": "~5.8.3",
"turbo": "^2.5.8",
"typescript": "~5.9.3",
"vitest": "^3.2.4"
},
"engines": {

View File

@@ -11,14 +11,17 @@ inputs:
description: 'The published name of a single package to release'
exclude:
description: 'Comma separated list of packages to exclude from release (if not depended upon)'
tag:
description: 'The tag to use, generally a feature name'
runs:
using: composite
steps:
- uses: oven-sh/setup-bun@v2
- run: bun packages/actions/src/releasePackages/index.ts
- run: bun $GITHUB_ACTION_PATH/index.ts
shell: bash
env:
INPUT_DEV: ${{ inputs.dev }}
INPUT_DRY: ${{ inputs.dry }}
INPUT_PACKAGE: ${{ inputs.package }}
INPUT_EXCLUDE: ${{ inputs.exclude }}
INPUT_TAG: ${{ inputs.tag }}

View File

@@ -26,9 +26,9 @@ export interface ReleaseEntry {
version: string;
}
async function fetchDevVersion(pkg: string) {
async function fetchDevVersion(pkg: string, tag: string) {
try {
const res = await fetch(`https://registry.npmjs.org/${pkg}/dev`);
const res = await fetch(`https://registry.npmjs.org/${pkg}/${tag}`);
if (!res.ok) return null;
const packument = (await res.json()) as PackumentVersion;
return packument.version;
@@ -37,12 +37,13 @@ async function fetchDevVersion(pkg: string) {
}
}
async function getReleaseEntries(dev: boolean, dry: boolean) {
async function getReleaseEntries(dry: boolean, devTag?: string) {
const releaseEntries: ReleaseEntry[] = [];
const packageList: pnpmTree[] =
await $`pnpm list --recursive --only-projects --filter {packages/\*} --prod --json`.json();
const commitHash = (await $`git rev-parse --short HEAD`.text()).trim();
const timestamp = Math.round(Date.now() / 1_000);
for (const pkg of packageList) {
// Don't release private packages ever (npm will error anyways)
@@ -56,8 +57,8 @@ async function getReleaseEntries(dev: boolean, dry: boolean) {
version: pkg.version,
};
if (dev) {
const devVersion = await fetchDevVersion(pkg.name);
if (devTag) {
const devVersion = await fetchDevVersion(pkg.name, devTag);
if (devVersion?.endsWith(commitHash)) {
// Write the currently released dev version so when pnpm publish runs on dependents they depend on the dev versions
if (dry) {
@@ -71,9 +72,9 @@ async function getReleaseEntries(dev: boolean, dry: boolean) {
release.version = devVersion;
} else if (dry) {
info(`[DRY] Bumping ${pkg.name} via git-cliff.`);
release.version = `${pkg.version}.DRY-dev.${Math.round(Date.now() / 1_000)}-${commitHash}`;
release.version = `${pkg.version}.DRY-${devTag}.${timestamp}-${commitHash}`;
} else {
await $`pnpm --filter=${pkg.name} run release --preid "dev.${Math.round(Date.now() / 1_000)}-${commitHash}" --skip-changelog`;
await $`pnpm --filter=${pkg.name} run release --preid "${devTag}.${timestamp}-${commitHash}" --skip-changelog`;
// Read again instead of parsing the output to be sure we're matching when checking against npm
const pkgJson = (await file(`${pkg.path}/package.json`).json()) as PackageJSON;
release.version = pkgJson.version;
@@ -128,8 +129,8 @@ async function getReleaseEntries(dev: boolean, dry: boolean) {
return releaseEntries;
}
export async function generateReleaseTree(dev: boolean, dry: boolean, packageName?: string, exclude?: string[]) {
let releaseEntries = await getReleaseEntries(dev, dry);
export async function generateReleaseTree(dry: boolean, devTag?: string, packageName?: string, exclude?: string[]) {
let releaseEntries = await getReleaseEntries(dry, devTag);
// Try to early return if the package doesn't have deps
if (packageName && packageName !== 'all') {
const releaseEntry = releaseEntries.find((entry) => entry.name === packageName);

View File

@@ -30,15 +30,29 @@ program
)
.option('--dry', 'skips actual publishing and outputs logs instead', dryInput)
.option('--dev', 'publishes development versions and skips tagging / github releases', devInput)
.option('--tag <tag>', 'tag to use for dev releases (defaults to "dev")', getInput('tag'))
.parse();
const { exclude, dry, dev } = program.opts<{ dev: boolean; dry: boolean; exclude: string[] }>();
const [packageName] = program.processedArgs as [string];
const {
exclude,
dry,
dev,
tag: inputTag,
} = program.opts<{ dev: boolean; dry: boolean; exclude: string[]; tag: string }>();
// All this because getInput('tag') will return empty string when not set :P
if (!dev && inputTag.length) {
throw new Error('The --tag option can only be used with --dev');
}
const tag = inputTag.length ? inputTag : dev ? 'dev' : undefined;
const [packageName] = program.processedArgs as [string];
const tree = await generateReleaseTree(dry, tag, packageName, exclude);
const tree = await generateReleaseTree(dev, dry, packageName, exclude);
for (const branch of tree) {
startGroup(`Releasing ${branch.map((entry) => `${entry.name}@${entry.version}`).join(', ')}`);
await Promise.all(branch.map(async (release) => releasePackage(release, dev, dry)));
await Promise.all(branch.map(async (release) => releasePackage(release, dry, tag)));
endGroup();
}

View File

@@ -24,7 +24,7 @@ async function gitTagAndRelease(release: ReleaseEntry, dry: boolean) {
return;
}
const commitHash = (await $`git rev-parse --short HEAD`.text()).trim();
const commitHash = (await $`git rev-parse HEAD`.text()).trim();
try {
await octokit?.rest.repos.createRelease({
@@ -41,7 +41,7 @@ async function gitTagAndRelease(release: ReleaseEntry, dry: boolean) {
}
}
export async function releasePackage(release: ReleaseEntry, dev: boolean, dry: boolean) {
export async function releasePackage(release: ReleaseEntry, dry: boolean, devTag?: string, doGitRelease = !devTag) {
// Sanity check against the registry first
if (await checkRegistry(release)) {
info(`${release.name}@${release.version} already published, skipping.`);
@@ -51,10 +51,11 @@ export async function releasePackage(release: ReleaseEntry, dev: boolean, dry: b
if (dry) {
info(`[DRY] Releasing ${release.name}@${release.version}`);
} else {
await $`pnpm --filter=${release.name} publish --provenance --no-git-checks ${dev ? '--tag=dev' : ''}`;
await $`pnpm --filter=${release.name} publish --provenance --no-git-checks ${devTag ? `--tag=${devTag}` : ''}`;
}
if (!dev) await gitTagAndRelease(release, dry);
// && !devTag just to be sure
if (doGitRelease && !devTag) await gitTagAndRelease(release, dry);
if (dry) return;
@@ -76,11 +77,19 @@ export async function releasePackage(release: ReleaseEntry, dev: boolean, dry: b
}, 15_000);
});
if (dev) {
if (devTag) {
// Send and forget, deprecations are less important than releasing other dev versions and can be done manually
void $`pnpm exec npm-deprecate --name "*dev*" --message "This version is deprecated. Please use a newer version." --package ${release.name}`
void $`pnpm exec npm-deprecate --name "*${devTag}*" --message "This version is deprecated. Please use a newer version." --package ${release.name}`
.nothrow()
// eslint-disable-next-line promise/prefer-await-to-then
.then(() => {});
}
// Evil, but I can't think of a cleaner mechanism
if (release.name === 'create-discord-bot') {
await $`pnpm --filter=create-discord-bot run rename-to-app`;
// eslint-disable-next-line require-atomic-updates
release.name = 'create-discord-app';
await releasePackage(release, dry, devTag, false);
}
}

View File

@@ -36,16 +36,16 @@
"@rushstack/node-core-library": "5.13.1"
},
"devDependencies": {
"@types/node": "^22.16.3",
"cross-env": "^7.0.3",
"eslint": "^9.30.1",
"@types/node": "^22.18.8",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"terser": "^5.43.1",
"terser": "^5.44.0",
"tsup": "^8.5.0",
"turbo": "^2.5.4",
"typescript": "~5.8.3"
"turbo": "^2.5.8",
"typescript": "~5.9.3"
}
}

View File

@@ -50,17 +50,17 @@
"@microsoft/tsdoc": "~0.15.1"
},
"devDependencies": {
"@types/node": "^22.16.3",
"cross-env": "^7.0.3",
"eslint": "^9.30.1",
"@types/node": "^22.18.8",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"terser": "^5.43.1",
"terser": "^5.44.0",
"tsup": "^8.5.0",
"turbo": "^2.5.4",
"typescript": "~5.8.3"
"turbo": "^2.5.8",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=22.12.0"

View File

@@ -64,18 +64,18 @@
},
"devDependencies": {
"@types/lodash": "^4.17.20",
"@types/node": "^22.16.3",
"@types/node": "^22.18.8",
"@types/resolve": "^1.20.6",
"@types/semver": "^7.7.0",
"cpy-cli": "^5.0.0",
"cross-env": "^7.0.3",
"eslint": "^9.30.1",
"@types/semver": "^7.7.1",
"cpy-cli": "^6.0.0",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"terser": "^5.43.1",
"terser": "^5.44.0",
"tsup": "^8.5.0",
"turbo": "^2.5.4"
"turbo": "^2.5.8"
}
}

View File

@@ -43,7 +43,8 @@ These examples use [ES modules](https://nodejs.org/api/esm.html#enabling).
import { PubSubRedisBroker } from '@discordjs/brokers';
import Redis from 'ioredis';
const broker = new PubSubRedisBroker(new Redis());
// Considering this only pushes events, the group and name are not important.
const broker = new PubSubRedisBroker(new Redis(), { group: 'noop', name: 'noop' });
await broker.publish('test', 'Hello World!');
await broker.destroy();
@@ -52,13 +53,22 @@ await broker.destroy();
import { PubSubRedisBroker } from '@discordjs/brokers';
import Redis from 'ioredis';
const broker = new PubSubRedisBroker(new Redis());
const broker = new PubSubRedisBroker(new Redis(), {
// This is the consumer group name. You should make sure to not re-use this
// across different applications in your stack, unless you absolutely know
// what you're doing.
group: 'subscribers',
// With the assumption that this service will scale to more than one instance,
// you MUST ensure `UNIQUE_CONSUMER_ID` is unique across all of them and
// also deterministic (i.e. if instance-1 restarts, it should still be instance-1)
name: `consumer-${UNIQUE_CONSUMER_ID}`,
});
broker.on('test', ({ data, ack }) => {
console.log(data);
void ack();
});
await broker.subscribe('subscribers', ['test']);
await broker.subscribe(['test']);
```
### RPC
@@ -68,7 +78,7 @@ await broker.subscribe('subscribers', ['test']);
import { RPCRedisBroker } from '@discordjs/brokers';
import Redis from 'ioredis';
const broker = new RPCRedisBroker(new Redis());
const broker = new RPCRedisBroker(new Redis(), { group: 'noop', name: 'noop' });
console.log(await broker.call('testcall', 'Hello World!'));
await broker.destroy();
@@ -77,14 +87,18 @@ await broker.destroy();
import { RPCRedisBroker } from '@discordjs/brokers';
import Redis from 'ioredis';
const broker = new RPCRedisBroker(new Redis());
const broker = new RPCRedisBroker(new Redis(), {
// Equivalent to the group/name in pubsub, refer to the previous example.
group: 'responders',
name: `consumer-${UNIQUE_ID}`,
});
broker.on('testcall', ({ data, ack, reply }) => {
console.log('responder', data);
void ack();
void reply(`Echo: ${data}`);
});
await broker.subscribe('responders', ['testcall']);
await broker.subscribe(['testcall']);
```
## Links

View File

@@ -68,25 +68,25 @@
"funding": "https://github.com/discordjs/discord.js?sponsor",
"dependencies": {
"@msgpack/msgpack": "^3.1.2",
"@vladfrangu/async_event_emitter": "^2.4.6",
"ioredis": "^5.6.1"
"@vladfrangu/async_event_emitter": "^2.4.7",
"ioredis": "^5.8.1"
},
"devDependencies": {
"@discordjs/api-extractor": "workspace:^",
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@types/node": "^22.16.3",
"@types/node": "^22.18.8",
"@vitest/coverage-v8": "^3.2.4",
"cross-env": "^7.0.3",
"cross-env": "^10.1.0",
"esbuild-plugin-version-injector": "^1.2.1",
"eslint": "^9.30.1",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"tsup": "^8.5.0",
"turbo": "^2.5.4",
"typescript": "~5.8.3",
"turbo": "^2.5.8",
"typescript": "~5.9.3",
"vitest": "^3.2.4"
},
"engines": {

View File

@@ -8,13 +8,25 @@ import { ReplyError } from 'ioredis';
import type { BaseBrokerOptions, IBaseBroker, ToEventMap } from '../Broker.js';
import { DefaultBrokerOptions } from '../Broker.js';
// For some reason ioredis doesn't have this typed, but it exists
type RedisReadGroupData = [Buffer, [Buffer, Buffer[]][]][];
// For some reason ioredis doesn't have those typed, but they exist
declare module 'ioredis' {
interface Redis {
xreadgroupBuffer(...args: (Buffer | string)[]): Promise<[Buffer, [Buffer, Buffer[]][]][] | null>;
xclaimBuffer(
key: Buffer | string,
group: Buffer | string,
consumer: Buffer | string,
minIdleTime: number,
id: Buffer | string,
...args: (Buffer | string)[]
): Promise<string[]>;
xreadgroupBuffer(...args: (Buffer | string)[]): Promise<RedisReadGroupData | null>;
}
}
export const kUseRandomGroupName = Symbol.for('djs.brokers.useRandomGroupName');
/**
* Options specific for a Redis broker
*/
@@ -23,25 +35,35 @@ export interface RedisBrokerOptions extends BaseBrokerOptions {
* How long to block for messages when polling
*/
blockTimeout?: number;
/**
* Consumer group name to use for this broker
* Consumer group name to use for this broker. For fanning out events, use {@link kUseRandomGroupName}
*
* @see {@link https://redis.io/commands/xreadgroup/}
*/
group: string;
group: string | typeof kUseRandomGroupName;
/**
* Max number of messages to poll at once
*/
maxChunk?: number;
/**
* How many times a message can be delivered to a consumer before it is considered dead.
* This is used to prevent messages from being stuck in the queue forever if a consumer is
* unable to process them.
*/
maxDeliveredTimes?: number;
/**
* How long a message should be idle for before allowing it to be claimed by another consumer.
* Note that too high of a value can lead to a high delay in processing messages during a service downscale,
* while too low of a value can lead to messages being too eagerly claimed by other consumers during an instance
* restart (which is most likely not actually that problematic)
*/
messageIdleTime?: number;
/**
* Unique consumer name.
*
* @see {@link https://redis.io/commands/xreadgroup/}
*/
name?: string;
name: string;
}
/**
@@ -49,10 +71,11 @@ export interface RedisBrokerOptions extends BaseBrokerOptions {
*/
export const DefaultRedisBrokerOptions = {
...DefaultBrokerOptions,
name: randomBytes(20).toString('hex'),
maxChunk: 10,
maxDeliveredTimes: 3,
messageIdleTime: 3_000,
blockTimeout: 5_000,
} as const satisfies Required<Omit<RedisBrokerOptions, 'group'>>;
} as const satisfies Required<Omit<RedisBrokerOptions, 'group' | 'name'>>;
/**
* Helper class with shared Redis logic
@@ -84,6 +107,14 @@ export abstract class BaseRedisBroker<
*/
protected readonly streamReadClient: Redis;
/**
* The group being used by this broker.
*
* @privateRemarks
* Stored as its own field to do the "use random group" resolution in the constructor.
*/
protected readonly group: string;
/**
* Whether this broker is currently polling events
*/
@@ -95,6 +126,7 @@ export abstract class BaseRedisBroker<
) {
super();
this.options = { ...DefaultRedisBrokerOptions, ...options };
this.group = this.options.group === kUseRandomGroupName ? randomBytes(16).toString('hex') : this.options.group;
redisClient.defineCommand('xcleangroup', {
numberOfKeys: 1,
lua: readFileSync(resolve(__dirname, '..', 'scripts', 'xcleangroup.lua'), 'utf8'),
@@ -111,7 +143,7 @@ export abstract class BaseRedisBroker<
events.map(async (event) => {
this.subscribedEvents.add(event as string);
try {
return await this.redisClient.xgroup('CREATE', event as string, this.options.group, 0, 'MKSTREAM');
return await this.redisClient.xgroup('CREATE', event as string, this.group, 0, 'MKSTREAM');
} catch (error) {
if (!(error instanceof ReplyError)) {
throw error;
@@ -141,7 +173,7 @@ export abstract class BaseRedisBroker<
}
/**
* Begins polling for events, firing them to {@link BaseRedisBroker.listen}
* Begins polling for events, firing them to {@link BaseRedisBroker.emitEvent}
*/
protected async listen(): Promise<void> {
if (this.listening) {
@@ -150,40 +182,24 @@ export abstract class BaseRedisBroker<
this.listening = true;
// Enter regular polling
while (this.subscribedEvents.size > 0) {
try {
const data = await this.streamReadClient.xreadgroupBuffer(
'GROUP',
this.options.group,
this.options.name,
'COUNT',
String(this.options.maxChunk),
'BLOCK',
String(this.options.blockTimeout),
'STREAMS',
...this.subscribedEvents,
...Array.from({ length: this.subscribedEvents.size }, () => '>'),
);
await this.claimAndEmitDeadEvents();
} catch (error) {
// @ts-expect-error: Intended
this.emit('error', error);
// We don't break here to keep the loop running even if dead event processing fails
}
try {
// As per docs, '>' means "give me a new message"
const data = await this.readGroup('>', this.options.blockTimeout);
if (!data) {
continue;
}
for (const [event, info] of data) {
for (const [id, packet] of info) {
const idx = packet.findIndex((value, idx) => value.toString('utf8') === 'data' && idx % 2 === 0);
if (idx < 0) {
continue;
}
const data = packet[idx + 1];
if (!data) {
continue;
}
this.emitEvent(id, this.options.group, event.toString('utf8'), this.options.decode(data));
}
}
await this.processMessages(data);
} catch (error) {
// @ts-expect-error: Intended
this.emit('error', error);
@@ -194,6 +210,103 @@ export abstract class BaseRedisBroker<
this.listening = false;
}
private async readGroup(fromId: string, block: number): Promise<RedisReadGroupData> {
const data = await this.streamReadClient.xreadgroupBuffer(
'GROUP',
this.group,
this.options.name,
'COUNT',
String(this.options.maxChunk),
'BLOCK',
String(block),
'STREAMS',
...this.subscribedEvents,
...Array.from({ length: this.subscribedEvents.size }, () => fromId),
);
return data ?? [];
}
private async processMessages(data: RedisReadGroupData): Promise<void> {
for (const [event, messages] of data) {
const eventName = event.toString('utf8');
for (const [id, packet] of messages) {
const idx = packet.findIndex((value, idx) => value.toString('utf8') === 'data' && idx % 2 === 0);
if (idx < 0) continue;
const payload = packet[idx + 1];
if (!payload) continue;
this.emitEvent(id, this.group, eventName, this.options.decode(payload));
}
}
}
private async claimAndEmitDeadEvents(): Promise<void> {
for (const stream of this.subscribedEvents) {
// Get up to N oldest pending messages (note: a pending message is a message that has been read, but never ACKed)
const pending = (await this.streamReadClient.xpending(
stream,
this.group,
'-',
'+',
this.options.maxChunk,
// See: https://redis.io/docs/latest/commands/xpending/#extended-form-of-xpending
)) as [id: string, consumer: string, idleMs: number, deliveredTimes: number][];
for (const [id, consumer, idleMs, deliveredTimes] of pending) {
// Technically xclaim checks for us anyway, but why not avoid an extra call?
if (idleMs < this.options.messageIdleTime) {
continue;
}
if (deliveredTimes > this.options.maxDeliveredTimes) {
// This message is dead. It has repeatedly failed being processed by a consumer.
await this.streamReadClient.xdel(stream, this.group, id);
continue;
}
// Try to claim the message if we don't already own it (this may fail if another consumer has already claimed it)
if (consumer !== this.options.name) {
const claimed = await this.streamReadClient.xclaimBuffer(
stream,
this.group,
this.options.name,
Math.max(this.options.messageIdleTime, 1),
id,
'JUSTID',
);
// Another consumer got the message before us
if (!claimed?.length) {
continue;
}
}
// Fetch message body
const entries = await this.streamReadClient.xrangeBuffer(stream, id, id);
// No idea how this could happen, frankly!
if (!entries?.length) {
continue;
}
const [msgId, fields] = entries[0]!;
const idx = fields.findIndex((value, idx) => value.toString('utf8') === 'data' && idx % 2 === 0);
if (idx < 0) {
continue;
}
const payload = fields[idx + 1];
if (!payload) {
continue;
}
this.emitEvent(msgId, this.group, stream, this.options.decode(payload));
}
}
}
/**
* Destroys the broker, closing all connections
*/

View File

@@ -24,7 +24,7 @@ export interface RPCRedisBrokerOptions extends RedisBrokerOptions {
export const DefaultRPCRedisBrokerOptions = {
...DefaultRedisBrokerOptions,
timeout: 5_000,
} as const satisfies Required<Omit<RPCRedisBrokerOptions, 'group'>>;
} as const satisfies Required<Omit<RPCRedisBrokerOptions, 'group' | 'name'>>;
/**
* RPC broker powered by Redis
@@ -121,7 +121,7 @@ export class RPCRedisBroker<TEvents extends Record<string, any[]>, TResponses ex
const payload: { ack(): Promise<void>; data: unknown; reply(data: unknown): Promise<void> } = {
data,
ack: async () => {
await this.redisClient.xack(event, this.options.group, id);
await this.redisClient.xack(event, this.group, id);
},
reply: async (data) => {
await this.redisClient.publish(`${event}:${id.toString()}`, this.options.encode(data));

View File

@@ -0,0 +1,109 @@
import type { APILabelComponent, APIStringSelectComponent, APITextInputComponent } from 'discord-api-types/v10';
import { ComponentType, TextInputStyle } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { LabelBuilder } from '../../src/index.js';
describe('Label components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid fields THEN builder does not throw', () => {
expect(() =>
new LabelBuilder()
.setLabel('label')
.setStringSelectMenuComponent((stringSelectMenu) =>
stringSelectMenu
.setCustomId('test')
.setOptions((stringSelectMenuOption) => stringSelectMenuOption.setLabel('label').setValue('value'))
.setRequired(),
)
.toJSON(),
).not.toThrow();
expect(() =>
new LabelBuilder()
.setLabel('label')
.setId(5)
.setTextInputComponent((textInput) =>
textInput.setCustomId('test').setStyle(TextInputStyle.Paragraph).setRequired(),
)
.toJSON(),
).not.toThrow();
});
test('GIVEN invalid fields THEN build does throw', () => {
expect(() => new LabelBuilder().toJSON()).toThrow();
expect(() => new LabelBuilder().setId(5).toJSON()).toThrow();
expect(() => new LabelBuilder().setLabel('label').toJSON()).toThrow();
expect(() =>
new LabelBuilder()
.setLabel('l'.repeat(1_000))
.setStringSelectMenuComponent((stringSelectMenu) => stringSelectMenu)
.toJSON(),
).toThrow();
});
test('GIVEN valid input THEN valid JSON outputs are given', () => {
const labelWithTextInputData = {
type: ComponentType.Label,
component: {
type: ComponentType.TextInput,
custom_id: 'custom_id',
placeholder: 'placeholder',
style: TextInputStyle.Paragraph,
} satisfies APITextInputComponent,
label: 'label',
description: 'description',
id: 5,
} satisfies APILabelComponent;
const labelWithStringSelectData = {
type: ComponentType.Label,
component: {
type: ComponentType.StringSelect,
custom_id: 'custom_id',
placeholder: 'placeholder',
options: [
{ label: 'first', value: 'first' },
{ label: 'second', value: 'second' },
],
required: true,
} satisfies APIStringSelectComponent,
label: 'label',
description: 'description',
id: 5,
} satisfies APILabelComponent;
expect(new LabelBuilder(labelWithTextInputData).toJSON()).toEqual(labelWithTextInputData);
expect(new LabelBuilder(labelWithStringSelectData).toJSON()).toEqual(labelWithStringSelectData);
expect(
new LabelBuilder()
.setTextInputComponent((textInput) =>
textInput.setCustomId('custom_id').setPlaceholder('placeholder').setStyle(TextInputStyle.Paragraph),
)
.setLabel('label')
.setDescription('description')
.setId(5)
.toJSON(),
).toEqual(labelWithTextInputData);
expect(
new LabelBuilder()
.setStringSelectMenuComponent((stringSelectMenu) =>
stringSelectMenu
.setCustomId('custom_id')
.setPlaceholder('placeholder')
.setOptions(
(stringSelectMenuOption) => stringSelectMenuOption.setLabel('first').setValue('first'),
(stringSelectMenuOption) => stringSelectMenuOption.setLabel('second').setValue('second'),
)
.setRequired(),
)
.setLabel('label')
.setDescription('description')
.setId(5)
.toJSON(),
).toEqual(labelWithStringSelectData);
});
});
});

View File

@@ -23,6 +23,7 @@ const selectMenuDataWithoutOptions = {
min_values: 1,
disabled: true,
placeholder: 'test',
required: false,
} as const;
const selectMenuData: APISelectMenuComponent = {

View File

@@ -8,13 +8,12 @@ describe('Text Input Components', () => {
describe('Assertion Tests', () => {
test('GIVEN valid fields THEN builder does not throw', () => {
expect(() => {
textInputComponent().setCustomId('foobar').setLabel('test').setStyle(TextInputStyle.Paragraph).toJSON();
textInputComponent().setCustomId('foobar').setStyle(TextInputStyle.Paragraph).toJSON();
}).not.toThrowError();
expect(() => {
textInputComponent()
.setCustomId('foobar')
.setLabel('test')
.setMaxLength(100)
.setMinLength(1)
.setPlaceholder('bar')
@@ -24,7 +23,7 @@ describe('Text Input Components', () => {
}).not.toThrowError();
expect(() => {
textInputComponent().setCustomId('Custom').setLabel('Guess').setStyle(TextInputStyle.Short).toJSON();
textInputComponent().setCustomId('Custom').setStyle(TextInputStyle.Short).toJSON();
}).not.toThrowError();
});
});
@@ -33,18 +32,17 @@ describe('Text Input Components', () => {
expect(() => textInputComponent().toJSON()).toThrowError();
expect(() => {
textInputComponent()
.setCustomId('test')
.setCustomId('a'.repeat(500))
.setMaxLength(100)
.setPlaceholder('hello')
.setStyle(TextInputStyle.Paragraph)
.setPlaceholder('a'.repeat(500))
.setStyle(3 as TextInputStyle)
.toJSON();
}).toThrowError();
});
test('GIVEN valid input THEN valid JSON outputs are given', () => {
const textInputData: APITextInputComponent = {
const textInputData = {
type: ComponentType.TextInput,
label: 'label',
custom_id: 'custom id',
placeholder: 'placeholder',
max_length: 100,
@@ -52,17 +50,16 @@ describe('Text Input Components', () => {
value: 'value',
required: false,
style: TextInputStyle.Paragraph,
};
} satisfies APITextInputComponent;
expect(new TextInputBuilder(textInputData).toJSON()).toEqual(textInputData);
expect(
textInputComponent()
.setCustomId(textInputData.custom_id)
.setLabel(textInputData.label)
.setPlaceholder(textInputData.placeholder!)
.setMaxLength(textInputData.max_length!)
.setMinLength(textInputData.min_length!)
.setValue(textInputData.value!)
.setPlaceholder(textInputData.placeholder)
.setMaxLength(textInputData.max_length)
.setMinLength(textInputData.min_length)
.setValue(textInputData.value)
.setRequired(textInputData.required)
.setStyle(textInputData.style)
.toJSON(),

View File

@@ -32,4 +32,11 @@ describe('Separator', () => {
expect(separator.toJSON()).toEqual({ type: ComponentType.Separator });
});
});
describe('Invalid id', () => {
test('GIVEN a separator with a set spacing and an invalid set id THEN throws error', () => {
const separator = new SeparatorBuilder().setSpacing(SeparatorSpacingSize.Large).setId(-1);
expect(() => separator.toJSON()).toThrowError();
});
});
});

View File

@@ -14,6 +14,11 @@ describe('TextDisplay', () => {
expect(textDisplay.toJSON()).toEqual({ type: ComponentType.TextDisplay, content: 'foo' });
});
test('GIVEN a text display with a set content with an invalid id THEN throws error', () => {
const textDisplay = new TextDisplayBuilder().setContent('foo').setId(5.5);
expect(() => textDisplay.toJSON()).toThrowError();
});
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');

View File

@@ -1,54 +1,60 @@
import { ComponentType, TextInputStyle, type APIModalInteractionResponseCallbackData } from 'discord-api-types/v10';
import { describe, test, expect } from 'vitest';
import { ActionRowBuilder, ModalBuilder, TextInputBuilder } from '../../src/index.js';
import { ModalBuilder, TextInputBuilder, LabelBuilder, TextDisplayBuilder } from '../../src/index.js';
const modal = () => new ModalBuilder();
const textInput = () =>
new ActionRowBuilder().addTextInputComponent(
new TextInputBuilder().setCustomId('text').setLabel(':3').setStyle(TextInputStyle.Short),
);
const label = () =>
new LabelBuilder()
.setLabel('label')
.setTextInputComponent(new TextInputBuilder().setCustomId('text').setStyle(TextInputStyle.Short));
const textDisplay = () => new TextDisplayBuilder().setContent('text');
describe('Modals', () => {
test('GIVEN valid fields THEN builder does not throw', () => {
expect(() => modal().setTitle('test').setCustomId('foobar').setActionRows(textInput()).toJSON()).not.toThrowError();
expect(() => modal().setTitle('test').setCustomId('foobar').addActionRows(textInput()).toJSON()).not.toThrowError();
expect(() =>
modal().setTitle('test').setCustomId('foobar').addLabelComponents(label()).toJSON(),
).not.toThrowError();
expect(() =>
modal().setTitle('test').setCustomId('foobar').addLabelComponents(label()).toJSON(),
).not.toThrowError();
expect(() =>
modal().setTitle('test').setCustomId('foobar').addTextDisplayComponents(textDisplay()).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
// @ts-expect-error: Custom id is invalid
expect(() => modal().setTitle('test').setCustomId(42).toJSON()).toThrowError();
});
test('GIVEN valid input THEN valid JSON outputs are given', () => {
const modalData: APIModalInteractionResponseCallbackData = {
const modalData = {
title: 'title',
custom_id: 'custom id',
components: [
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.TextInput,
label: 'label',
style: TextInputStyle.Paragraph,
custom_id: 'custom id',
},
],
type: ComponentType.Label,
id: 33,
label: 'label',
description: 'description',
component: {
type: ComponentType.TextInput,
style: TextInputStyle.Paragraph,
custom_id: 'custom id',
},
},
{
type: ComponentType.ActionRow,
components: [
{
type: ComponentType.TextInput,
label: 'label',
style: TextInputStyle.Paragraph,
custom_id: 'custom id',
},
],
type: ComponentType.TextDisplay,
content: 'yooooooooo',
},
],
};
} satisfies APIModalInteractionResponseCallbackData;
expect(new ModalBuilder(modalData).toJSON()).toEqual(modalData);
@@ -56,16 +62,14 @@ describe('Modals', () => {
modal()
.setTitle(modalData.title)
.setCustomId('custom id')
.setActionRows(
new ActionRowBuilder().addTextInputComponent(
new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph),
),
.addLabelComponents(
new LabelBuilder()
.setId(33)
.setLabel('label')
.setDescription('description')
.setTextInputComponent(new TextInputBuilder().setCustomId('custom id').setStyle(TextInputStyle.Paragraph)),
)
.addActionRows([
new ActionRowBuilder().addTextInputComponent(
new TextInputBuilder().setCustomId('custom id').setLabel('label').setStyle(TextInputStyle.Paragraph),
),
])
.addTextDisplayComponents((textDisplay) => textDisplay.setContent('yooooooooo'))
.toJSON(),
).toEqual(modalData);
});

View File

@@ -19,7 +19,7 @@ describe('Message', () => {
test('GIVEN bad action row THEN it throws', () => {
const message = new MessageBuilder().addActionRowComponents((row) =>
row.addTextInputComponent((input) => input.setCustomId('abc').setLabel('def')),
row.addTextInputComponent((input) => input.setCustomId('abc')),
);
expect(() => message.toJSON()).toThrow();
});

View File

@@ -66,27 +66,27 @@
"funding": "https://github.com/discordjs/discord.js?sponsor",
"dependencies": {
"@discordjs/util": "workspace:^",
"discord-api-types": "^0.38.16",
"discord-api-types": "^0.38.29",
"ts-mixer": "^6.0.4",
"tslib": "^2.8.1",
"zod": "^4.0.5"
"zod": "^4.1.12"
},
"devDependencies": {
"@discordjs/api-extractor": "workspace:^",
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@types/node": "^22.16.3",
"@types/node": "^22.18.8",
"@vitest/coverage-v8": "^3.2.4",
"cross-env": "^7.0.3",
"cross-env": "^10.1.0",
"esbuild-plugin-version-injector": "^1.2.1",
"eslint": "^9.30.1",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"tsup": "^8.5.0",
"turbo": "^2.5.4",
"typescript": "~5.8.3",
"turbo": "^2.5.8",
"typescript": "~5.9.3",
"vitest": "^3.2.4"
},
"engines": {

View File

@@ -1,6 +1,7 @@
import { Locale } from 'discord-api-types/v10';
import { z } from 'zod';
export const idPredicate = z.int().min(0).max(2_147_483_647).optional();
export const customIdPredicate = z.string().min(1).max(100);
export const memberPermissionsPredicate = z.coerce.bigint();

View File

@@ -1,6 +1,6 @@
import { ButtonStyle, ChannelType, ComponentType, SelectMenuDefaultValueType } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate } from '../Assertions.js';
import { idPredicate, customIdPredicate } from '../Assertions.js';
const labelPredicate = z.string().min(1).max(80);
@@ -52,6 +52,7 @@ export const buttonPredicate = z.discriminatedUnion('style', [
]);
const selectMenuBasePredicate = z.object({
id: idPredicate,
placeholder: z.string().max(150).optional(),
min_values: z.number().min(0).max(25).optional(),
max_values: z.number().min(0).max(25).optional(),
@@ -116,13 +117,26 @@ export const selectMenuStringPredicate = selectMenuBasePredicate
input: minimum,
});
if (ctx.value.max_values !== undefined && ctx.value.options.length < ctx.value.max_values) {
addIssue('max_values', ctx.value.max_values);
}
if (ctx.value.min_values !== undefined && ctx.value.options.length < ctx.value.min_values) {
addIssue('min_values', ctx.value.min_values);
}
if (
ctx.value.min_values !== undefined &&
ctx.value.max_values !== undefined &&
ctx.value.min_values > ctx.value.max_values
) {
ctx.issues.push({
code: 'too_big',
message: `The maximum amount of options must be greater than or equal to the minimum amount of options`,
inclusive: true,
maximum: ctx.value.max_values,
type: 'number',
path: ['min_values'],
origin: 'number',
input: ctx.value.min_values,
});
}
});
export const selectMenuUserPredicate = selectMenuBasePredicate.extend({
@@ -135,6 +149,7 @@ export const selectMenuUserPredicate = selectMenuBasePredicate.extend({
});
export const actionRowPredicate = z.object({
id: idPredicate,
type: z.literal(ComponentType.ActionRow),
components: z.union([
z

View File

@@ -17,6 +17,7 @@ import {
} from './button/CustomIdButton.js';
import { LinkButtonBuilder } from './button/LinkButton.js';
import { PremiumButtonBuilder } from './button/PremiumButton.js';
import { LabelBuilder } from './label/Label.js';
import { ChannelSelectMenuBuilder } from './selectMenu/ChannelSelectMenu.js';
import { MentionableSelectMenuBuilder } from './selectMenu/MentionableSelectMenu.js';
import { RoleSelectMenuBuilder } from './selectMenu/RoleSelectMenu.js';
@@ -54,7 +55,7 @@ export type MessageComponentBuilder =
/**
* The builders that may be used for modals.
*/
export type ModalComponentBuilder = ActionRowBuilder | ModalActionRowComponentBuilder;
export type ModalComponentBuilder = ActionRowBuilder | LabelBuilder | ModalActionRowComponentBuilder;
/**
* Any button builder
@@ -88,6 +89,11 @@ export type ModalActionRowComponentBuilder = TextInputBuilder;
*/
export type AnyActionRowComponentBuilder = MessageActionRowComponentBuilder | ModalActionRowComponentBuilder;
/**
* Any modal component builder.
*/
export type AnyModalComponentBuilder = LabelBuilder | TextDisplayBuilder;
/**
* Components here are mapped to their respective builder.
*/
@@ -152,6 +158,10 @@ export interface MappedComponentTypes {
* The container component type is associated with a {@link ContainerBuilder}.
*/
[ComponentType.Container]: ContainerBuilder;
/**
* The label component type is associated with a {@link LabelBuilder}.
*/
[ComponentType.Label]: LabelBuilder;
}
/**
@@ -213,6 +223,8 @@ export function createComponentBuilder(
return new SectionBuilder(data);
case ComponentType.Container:
return new ContainerBuilder(data);
case ComponentType.Label:
return new LabelBuilder(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}`);

View File

@@ -0,0 +1,26 @@
import { ComponentType } from 'discord-api-types/v10';
import { z } from 'zod';
import { idPredicate } from '../../Assertions';
import {
selectMenuChannelPredicate,
selectMenuMentionablePredicate,
selectMenuRolePredicate,
selectMenuStringPredicate,
selectMenuUserPredicate,
} from '../Assertions';
import { textInputPredicate } from '../textInput/Assertions';
export const labelPredicate = z.object({
id: idPredicate,
type: z.literal(ComponentType.Label),
label: z.string().min(1).max(45),
description: z.string().min(1).max(100).optional(),
component: z.union([
selectMenuStringPredicate,
textInputPredicate,
selectMenuUserPredicate,
selectMenuRolePredicate,
selectMenuMentionablePredicate,
selectMenuChannelPredicate,
]),
});

View File

@@ -0,0 +1,200 @@
import type {
APIChannelSelectComponent,
APILabelComponent,
APIMentionableSelectComponent,
APIRoleSelectComponent,
APIStringSelectComponent,
APITextInputComponent,
APIUserSelectComponent,
} from 'discord-api-types/v10';
import { ComponentType } from 'discord-api-types/v10';
import { resolveBuilder } from '../../util/resolveBuilder.js';
import { validate } from '../../util/validation.js';
import { ComponentBuilder } from '../Component.js';
import { createComponentBuilder } from '../Components.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
| MentionableSelectMenuBuilder
| RoleSelectMenuBuilder
| StringSelectMenuBuilder
| TextInputBuilder
| UserSelectMenuBuilder;
}
/**
* A builder that creates API-compatible JSON data for labels.
*/
export class LabelBuilder extends ComponentBuilder<APILabelComponent> {
/**
* @internal
*/
protected 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,
* }).setContent('new text');
* ```
*/
public constructor(data: Partial<APILabelComponent> = {}) {
super();
const { component, ...rest } = data;
this.data = {
...structuredClone(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;
}
/**
* {@inheritDoc ComponentBuilder.toJSON}
*/
public override toJSON(validationOverride?: boolean): APILabelComponent {
const { component, ...rest } = this.data;
const data = {
...structuredClone(rest),
// The label predicate validates the component.
component: component?.toJSON(false),
};
validate(labelPredicate, data, validationOverride);
return data as APILabelComponent;
}
}

View File

@@ -15,7 +15,7 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
* @internal
*/
protected abstract override readonly data: Partial<
Pick<Data, 'custom_id' | 'disabled' | 'id' | 'max_values' | 'min_values' | 'placeholder'>
Pick<Data, 'custom_id' | 'disabled' | 'id' | 'max_values' | 'min_values' | 'placeholder' | 'required'>
>;
/**
@@ -75,4 +75,15 @@ export abstract class BaseSelectMenuBuilder<Data extends APISelectMenuComponent>
this.data.disabled = disabled;
return this;
}
/**
* Sets whether this select menu is required.
*
* @remarks Only for use in modals.
* @param required - Whether this string select menu is required
*/
public setRequired(required = true) {
this.data.required = required;
return this;
}
}

View File

@@ -9,6 +9,7 @@ import { StringSelectMenuOptionBuilder } from './StringSelectMenuOption.js';
export interface StringSelectMenuData extends Partial<Omit<APIStringSelectComponent, 'options'>> {
options: StringSelectMenuOptionBuilder[];
required?: boolean;
}
/**

View File

@@ -1,11 +1,11 @@
import { ComponentType, TextInputStyle } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate } from '../../Assertions.js';
import { customIdPredicate, idPredicate } from '../../Assertions.js';
export const textInputPredicate = z.object({
id: idPredicate,
type: z.literal(ComponentType.TextInput),
custom_id: customIdPredicate,
label: z.string().min(1).max(45),
style: z.enum(TextInputStyle),
min_length: z.number().min(0).max(4_000).optional(),
max_length: z.number().min(1).max(4_000).optional(),

View File

@@ -21,7 +21,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* ```ts
* const textInput = new TextInputBuilder({
* custom_id: 'a cool text input',
* label: 'Type something',
* placeholder: 'Type something',
* style: TextInputStyle.Short,
* });
* ```
@@ -29,7 +29,7 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
* Creating a text input using setters and API data:
* ```ts
* const textInput = new TextInputBuilder({
* label: 'Type something else',
* placeholder: 'Type something else',
* })
* .setCustomId('woah')
* .setStyle(TextInputStyle.Paragraph);
@@ -50,16 +50,6 @@ export class TextInputBuilder extends ComponentBuilder<APITextInputComponent> {
return this;
}
/**
* Sets the label for this text input.
*
* @param label - The label to use
*/
public setLabel(label: string) {
this.data.label = label;
return this;
}
/**
* Sets the style for this text input.
*

View File

@@ -1,5 +1,6 @@
import { ComponentType, SeparatorSpacingSize } from 'discord-api-types/v10';
import { z } from 'zod';
import { idPredicate } from '../../Assertions.js';
import { actionRowPredicate } from '../Assertions.js';
const unfurledMediaItemPredicate = z.object({
@@ -7,6 +8,8 @@ const unfurledMediaItemPredicate = z.object({
});
export const thumbnailPredicate = z.object({
type: z.literal(ComponentType.Thumbnail),
id: idPredicate,
media: unfurledMediaItemPredicate,
description: z.string().min(1).max(1_024).nullish(),
spoiler: z.boolean().optional(),
@@ -17,30 +20,41 @@ const unfurledMediaItemAttachmentOnlyPredicate = z.object({
});
export const filePredicate = z.object({
type: z.literal(ComponentType.File),
id: idPredicate,
file: unfurledMediaItemAttachmentOnlyPredicate,
spoiler: z.boolean().optional(),
});
export const separatorPredicate = z.object({
type: z.literal(ComponentType.Separator),
id: idPredicate,
divider: z.boolean().optional(),
spacing: z.enum(SeparatorSpacingSize).optional(),
});
export const textDisplayPredicate = z.object({
type: z.literal(ComponentType.TextDisplay),
id: idPredicate,
content: z.string().min(1).max(4_000),
});
export const mediaGalleryItemPredicate = z.object({
id: idPredicate,
media: unfurledMediaItemPredicate,
description: z.string().min(1).max(1_024).nullish(),
spoiler: z.boolean().optional(),
});
export const mediaGalleryPredicate = z.object({
type: z.literal(ComponentType.MediaGallery),
id: idPredicate,
items: z.array(mediaGalleryItemPredicate).min(1).max(10),
});
export const sectionPredicate = z.object({
type: z.literal(ComponentType.Section),
id: idPredicate,
components: z.array(textDisplayPredicate).min(1).max(3),
accessory: z.union([
z.object({ type: z.literal(ComponentType.Button) }),
@@ -49,6 +63,8 @@ export const sectionPredicate = z.object({
});
export const containerPredicate = z.object({
type: z.literal(ComponentType.Container),
id: idPredicate,
components: z
.array(
z.union([

View File

@@ -4,6 +4,9 @@ export * from './components/button/CustomIdButton.js';
export * from './components/button/LinkButton.js';
export * from './components/button/PremiumButton.js';
export * from './components/label/Label.js';
export * from './components/label/Assertions.js';
export * from './components/selectMenu/BaseSelectMenu.js';
export * from './components/selectMenu/ChannelSelectMenu.js';
export * from './components/selectMenu/MentionableSelectMenu.js';

View File

@@ -1,6 +1,8 @@
import { ComponentType } from 'discord-api-types/v10';
import { z } from 'zod';
import { customIdPredicate } from '../../Assertions.js';
import { labelPredicate } from '../../components/label/Assertions.js';
import { textDisplayPredicate } from '../../components/v2/Assertions.js';
const titlePredicate = z.string().min(1).max(45);
@@ -8,13 +10,17 @@ export const modalPredicate = z.object({
title: titlePredicate,
custom_id: customIdPredicate,
components: z
.object({
type: z.literal(ComponentType.ActionRow),
components: z
.object({ type: z.literal(ComponentType.TextInput) })
.array()
.length(1),
})
.union([
z.object({
type: z.literal(ComponentType.ActionRow),
components: z
.object({ type: z.literal(ComponentType.TextInput) })
.array()
.length(1),
}),
labelPredicate,
textDisplayPredicate,
])
.array()
.min(1)
.max(5),

View File

@@ -1,18 +1,21 @@
import type { JSONEncodable } from '@discordjs/util';
import type {
APIActionRowComponent,
APIComponentInModalActionRow,
APILabelComponent,
APIModalInteractionResponseCallbackData,
APITextDisplayComponent,
} from 'discord-api-types/v10';
import { ActionRowBuilder } from '../../components/ActionRow.js';
import type { ActionRowBuilder } from '../../components/ActionRow.js';
import type { AnyModalComponentBuilder } from '../../components/Components.js';
import { createComponentBuilder } from '../../components/Components.js';
import { LabelBuilder } from '../../components/label/Label.js';
import { TextDisplayBuilder } from '../../components/v2/TextDisplay.js';
import { normalizeArray, type RestOrArray } from '../../util/normalizeArray.js';
import { resolveBuilder } from '../../util/resolveBuilder.js';
import { validate } from '../../util/validation.js';
import { modalPredicate } from './Assertions.js';
export interface ModalBuilderData extends Partial<Omit<APIModalInteractionResponseCallbackData, 'components'>> {
components: ActionRowBuilder[];
components: (ActionRowBuilder | AnyModalComponentBuilder)[];
}
/**
@@ -27,7 +30,7 @@ export class ModalBuilder implements JSONEncodable<APIModalInteractionResponseCa
/**
* The components within this modal.
*/
public get components(): readonly ActionRowBuilder[] {
public get components(): readonly (ActionRowBuilder | AnyModalComponentBuilder)[] {
return this.data.components;
}
@@ -66,19 +69,15 @@ export class ModalBuilder implements JSONEncodable<APIModalInteractionResponseCa
}
/**
* Adds action rows to this modal.
* Adds label components to this modal.
*
* @param components - The components to add
*/
public addActionRows(
...components: RestOrArray<
| ActionRowBuilder
| APIActionRowComponent<APIComponentInModalActionRow>
| ((builder: ActionRowBuilder) => ActionRowBuilder)
>
public addLabelComponents(
...components: RestOrArray<APILabelComponent | LabelBuilder | ((builder: LabelBuilder) => LabelBuilder)>
) {
const normalized = normalizeArray(components);
const resolved = normalized.map((row) => resolveBuilder(row, ActionRowBuilder));
const resolved = normalized.map((label) => resolveBuilder(label, LabelBuilder));
this.data.components.push(...resolved);
@@ -86,64 +85,54 @@ export class ModalBuilder implements JSONEncodable<APIModalInteractionResponseCa
}
/**
* Sets the action rows for this modal.
* Adds text display components to this modal.
*
* @param components - The components to set
* @param components - The components to add
*/
public setActionRows(
public addTextDisplayComponents(
...components: RestOrArray<
| ActionRowBuilder
| APIActionRowComponent<APIComponentInModalActionRow>
| ((builder: ActionRowBuilder) => ActionRowBuilder)
APITextDisplayComponent | TextDisplayBuilder | ((builder: TextDisplayBuilder) => TextDisplayBuilder)
>
) {
const normalized = normalizeArray(components);
this.spliceActionRows(0, this.data.components.length, ...normalized);
const resolved = normalized.map((row) => resolveBuilder(row, TextDisplayBuilder));
this.data.components.push(...resolved);
return this;
}
/**
* Removes, replaces, or inserts action rows for this modal.
* Removes, replaces, or inserts components for this modal.
*
* @remarks
* This method behaves similarly
* to {@link https://developer.mozilla.org/docs/Web/JavaScript/Reference/Global_Objects/Array/splice | Array.prototype.splice()}.
* The maximum amount of action rows that can be added is 5.
* The maximum amount of components that can be added is 5.
*
* It's useful for modifying and adjusting order of the already-existing action rows of a modal.
* It's useful for modifying and adjusting order of the already-existing components of a modal.
* @example
* Remove the first action row:
* Remove the first component:
* ```ts
* embed.spliceActionRows(0, 1);
* modal.spliceComponents(0, 1);
* ```
* @example
* Remove the first n action rows:
* Remove the first n components:
* ```ts
* const n = 4;
* embed.spliceActionRows(0, n);
* modal.spliceComponents(0, n);
* ```
* @example
* Remove the last action row:
* Remove the last component:
* ```ts
* embed.spliceActionRows(-1, 1);
* modal.spliceComponents(-1, 1);
* ```
* @param index - The index to start at
* @param deleteCount - The number of action rows to remove
* @param rows - The replacing action row objects
* @param deleteCount - The number of components to remove
* @param components - The replacing components
*/
public spliceActionRows(
index: number,
deleteCount: number,
...rows: (
| ActionRowBuilder
| APIActionRowComponent<APIComponentInModalActionRow>
| ((builder: ActionRowBuilder) => ActionRowBuilder)
)[]
): this {
const resolved = rows.map((row) => resolveBuilder(row, ActionRowBuilder));
this.data.components.splice(index, deleteCount, ...resolved);
public spliceComponents(index: number, deleteCount: number, ...components: AnyModalComponentBuilder[]): this {
this.data.components.splice(index, deleteCount, ...components);
return this;
}

View File

@@ -64,18 +64,18 @@
"@discordjs/api-extractor": "workspace:^",
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@types/node": "^22.16.3",
"@types/node": "^22.18.8",
"@vitest/coverage-v8": "^3.2.4",
"cross-env": "^7.0.3",
"cross-env": "^10.1.0",
"esbuild-plugin-version-injector": "^1.2.1",
"eslint": "^9.30.1",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"tsup": "^8.5.0",
"turbo": "^2.5.4",
"typescript": "~5.8.3",
"turbo": "^2.5.8",
"typescript": "~5.9.3",
"vitest": "^3.2.4"
},
"engines": {

View File

@@ -2,6 +2,7 @@ import { REST } from '@discordjs/rest';
import type {
APIActionRowComponent,
APIComponentInModalActionRow,
RESTGetAPIChannelThreadMemberResult,
RESTPostAPIInteractionCallbackWithResponseResult,
} from 'discord-api-types/v10';
import { expectTypeOf, describe, test } from 'vitest';
@@ -158,3 +159,20 @@ describe('Interaction with_response overloads.', () => {
Promise<RESTPostAPIInteractionCallbackWithResponseResult | undefined>
>());
});
describe('Thread member overloads.', () => {
test('Getting a thread member with with_member makes the guild member present.', () =>
expectTypeOf(api.threads.getMember(SNOWFLAKE, SNOWFLAKE, { with_member: true })).toEqualTypeOf<
Promise<Required<Pick<RESTGetAPIChannelThreadMemberResult, 'member'>> & RESTGetAPIChannelThreadMemberResult>
>());
test('Getting a thread member without with_member returns RESTGetAPIChannelThreadMemberResult.', () => {
expectTypeOf(api.threads.getMember(SNOWFLAKE, SNOWFLAKE, { with_member: false })).toEqualTypeOf<
Promise<RESTGetAPIChannelThreadMemberResult>
>();
expectTypeOf(api.threads.getMember(SNOWFLAKE, SNOWFLAKE)).toEqualTypeOf<
Promise<RESTGetAPIChannelThreadMemberResult>
>();
});
});

View File

@@ -69,25 +69,25 @@
"@discordjs/util": "workspace:^",
"@discordjs/ws": "workspace:^",
"@sapphire/snowflake": "^3.5.5",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.16"
"@vladfrangu/async_event_emitter": "^2.4.7",
"discord-api-types": "^0.38.29"
},
"devDependencies": {
"@discordjs/api-extractor": "workspace:^",
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@types/node": "^22.16.3",
"@types/node": "^22.18.8",
"@vitest/coverage-v8": "^3.2.4",
"cross-env": "^7.0.3",
"cross-env": "^10.1.0",
"esbuild-plugin-version-injector": "^1.2.1",
"eslint": "^9.30.1",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"tsup": "^8.5.0",
"turbo": "^2.5.4",
"typescript": "~5.8.3",
"turbo": "^2.5.8",
"typescript": "~5.9.3",
"vitest": "^3.2.4"
},
"engines": {

View File

@@ -0,0 +1,34 @@
import { Routes } from 'discord-api-types/v10';
import { glob, readFile } from 'node:fs/promises';
const usedRoutes = new Set();
const ignoredRoutes = new Set([
// Deprecated
'channelPins',
'channelPin',
'guilds',
'guildCurrentMemberNickname',
'guildMFA',
'nitroStickerPacks',
]);
for await (const file of glob('src/api/*.ts')) {
const content = await readFile(file, 'utf-8');
const routes = content.matchAll(/Routes\.([\w\d_]+)/g);
for (const route of routes) {
usedRoutes.add(route[1]);
}
}
const unusedRoutes = Object.keys(Routes).filter((route) => !usedRoutes.has(route) && !ignoredRoutes.has(route));
if (unusedRoutes.length > 0) {
console.warn('The following routes are not implemented:');
for (const route of unusedRoutes) {
console.warn(` - ${route}`);
}
} else {
console.log('No missing routes.');
}

View File

@@ -1,10 +1,9 @@
/* eslint-disable jsdoc/check-param-names */
import { makeURLSearchParams, type RawFile, type REST, type RequestData } from '@discordjs/rest';
import { makeURLSearchParams, type RawFile, type RequestData, type REST } from '@discordjs/rest';
import {
Routes,
type RESTPostAPIChannelWebhookJSONBody,
type RESTPostAPIChannelWebhookResult,
type APIThreadChannel,
type RESTDeleteAPIChannelResult,
type RESTGetAPIChannelInvitesResult,
type RESTGetAPIChannelMessageReactionUsersQuery,
@@ -18,8 +17,8 @@ import {
type RESTGetAPIChannelThreadsArchivedQuery,
type RESTGetAPIChannelUsersThreadsArchivedResult,
type RESTGetAPIChannelWebhooksResult,
type RESTPatchAPIChannelMessageJSONBody,
type RESTPatchAPIChannelJSONBody,
type RESTPatchAPIChannelMessageJSONBody,
type RESTPatchAPIChannelMessageResult,
type RESTPatchAPIChannelResult,
type RESTPostAPIChannelFollowersResult,
@@ -28,14 +27,16 @@ import {
type RESTPostAPIChannelMessageCrosspostResult,
type RESTPostAPIChannelMessageJSONBody,
type RESTPostAPIChannelMessageResult,
type RESTPutAPIChannelPermissionJSONBody,
type Snowflake,
type RESTPostAPIChannelThreadsJSONBody,
type RESTPostAPIChannelThreadsResult,
type APIThreadChannel,
type RESTPostAPIChannelWebhookJSONBody,
type RESTPostAPIChannelWebhookResult,
type RESTPostAPIGuildForumThreadsJSONBody,
type RESTPostAPISoundboardSendSoundJSONBody,
type RESTPostAPISendSoundboardSoundResult,
type RESTPostAPISoundboardSendSoundJSONBody,
type RESTPutAPIChannelPermissionJSONBody,
type RESTPutAPIChannelRecipientJSONBody,
type Snowflake,
} from 'discord-api-types/v10';
export interface StartForumThreadOptions extends RESTPostAPIGuildForumThreadsJSONBody {
@@ -708,4 +709,45 @@ export class ChannelsAPI {
signal,
}) as Promise<RESTPostAPISendSoundboardSoundResult>;
}
/**
* Adds a recipient to a group DM channel
*
* @see {@link https://discord.com/developers/docs/resources/channel#group-dm-add-recipient}
* @param channelId - The id of the channel to add the recipient to
* @param userId - The id of the user to add as a recipient
* @param body - The data for adding the recipient
* @param options - The options for adding the recipient
*/
public async addGroupDMRecipient(
channelId: Snowflake,
userId: Snowflake,
body: RESTPutAPIChannelRecipientJSONBody,
{ auth, signal }: Pick<RequestData, 'auth' | 'signal'> = {},
) {
await this.rest.put(Routes.channelRecipient(channelId, userId), {
auth,
body,
signal,
});
}
/**
* Removes a recipient from a group DM channel
*
* @see {@link https://discord.com/developers/docs/resources/channel#group-dm-remove-recipient}
* @param channelId - The id of the channel to remove the recipient from
* @param userId - The id of the user to remove as a recipient
* @param options - The options for removing the recipient
*/
public async removeGroupDMRecipient(
channelId: Snowflake,
userId: Snowflake,
{ auth, signal }: Pick<RequestData, 'auth' | 'signal'> = {},
) {
await this.rest.delete(Routes.channelRecipient(channelId, userId), {
auth,
signal,
});
}
}

View File

@@ -0,0 +1,34 @@
/* eslint-disable jsdoc/check-param-names */
import type { RequestData, REST } from '@discordjs/rest';
import { Routes, type RESTGetAPIGatewayBotResult, type RESTGetAPIGatewayResult } from 'discord-api-types/v10';
export class GatewayAPI {
public constructor(private readonly rest: REST) {}
/**
* Gets gateway information.
*
* @see {@link https://discord.com/developers/docs/events/gateway#get-gateway}
* @param options - The options for fetching the gateway information
*/
public async get({ signal }: Pick<RequestData, 'signal'> = {}) {
return this.rest.get(Routes.gateway(), {
auth: false,
signal,
}) as Promise<RESTGetAPIGatewayResult>;
}
/**
* Gets gateway information with additional metadata.
*
* @see {@link https://discord.com/developers/docs/events/gateway#get-gateway-bot}
* @param options - The options for fetching the gateway information
*/
public async getBot({ auth, signal }: Pick<RequestData, 'auth' | 'signal'> = {}) {
return this.rest.get(Routes.gatewayBot(), {
auth,
signal,
}) as Promise<RESTGetAPIGatewayBotResult>;
}
}

View File

@@ -89,6 +89,8 @@ import {
type RESTPostAPIGuildTemplatesJSONBody,
type RESTPostAPIGuildTemplatesResult,
type RESTPutAPIGuildBanJSONBody,
type RESTPutAPIGuildIncidentActionsJSONBody,
type RESTPutAPIGuildIncidentActionsResult,
type RESTPutAPIGuildMemberJSONBody,
type RESTPutAPIGuildMemberResult,
type RESTPutAPIGuildOnboardingJSONBody,
@@ -1439,4 +1441,24 @@ export class GuildsAPI {
) {
await this.rest.delete(Routes.guildSoundboardSound(guildId, soundId), { auth, reason, signal });
}
/**
* Modifies incident actions for a guild.
*
* @see {@link https://discord.com/developers/docs/resources/guild#modify-guild-incident-actions}
* @param guildId - The id of the guild
* @param body - The data for modifying guild incident actions
* @param options - The options for modifying guild incident actions
*/
public async editIncidentActions(
guildId: Snowflake,
body: RESTPutAPIGuildIncidentActionsJSONBody,
{ auth, signal }: Pick<RequestData, 'auth' | 'signal'> = {},
) {
return this.rest.put(Routes.guildIncidentActions(guildId), {
auth,
body,
signal,
}) as Promise<RESTPutAPIGuildIncidentActionsResult>;
}
}

View File

@@ -2,6 +2,7 @@ import type { REST } from '@discordjs/rest';
import { ApplicationCommandsAPI } from './applicationCommands.js';
import { ApplicationsAPI } from './applications.js';
import { ChannelsAPI } from './channel.js';
import { GatewayAPI } from './gateway.js';
import { GuildsAPI } from './guild.js';
import { InteractionsAPI } from './interactions.js';
import { InvitesAPI } from './invite.js';
@@ -20,6 +21,7 @@ import { WebhooksAPI } from './webhook.js';
export * from './applicationCommands.js';
export * from './applications.js';
export * from './channel.js';
export * from './gateway.js';
export * from './guild.js';
export * from './interactions.js';
export * from './invite.js';
@@ -42,6 +44,8 @@ export class API {
public readonly channels: ChannelsAPI;
public readonly gateway: GatewayAPI;
public readonly guilds: GuildsAPI;
public readonly interactions: InteractionsAPI;
@@ -74,6 +78,7 @@ export class API {
this.applicationCommands = new ApplicationCommandsAPI(rest);
this.applications = new ApplicationsAPI(rest);
this.channels = new ChannelsAPI(rest);
this.gateway = new GatewayAPI(rest);
this.guilds = new GuildsAPI(rest);
this.invites = new InvitesAPI(rest);
this.monetization = new MonetizationAPI(rest);

View File

@@ -1,9 +1,10 @@
/* eslint-disable jsdoc/check-param-names */
import type { RequestData, REST } from '@discordjs/rest';
import { makeURLSearchParams, type RequestData, type REST } from '@discordjs/rest';
import {
Routes,
type APIThreadMember,
type RESTGetAPIChannelThreadMemberQuery,
type RESTGetAPIChannelThreadMemberResult,
type RESTGetAPIChannelThreadMembersResult,
type Snowflake,
} from 'discord-api-types/v10';
@@ -71,14 +72,43 @@ export class ThreadsAPI {
* @see {@link https://discord.com/developers/docs/resources/channel#get-thread-member}
* @param threadId - The id of the thread to fetch the member from
* @param userId - The id of the user
* @param query - The query for fetching the member
* @param options - The options for fetching the member
*/
public async getMember(
threadId: Snowflake,
userId: Snowflake,
query: RESTGetAPIChannelThreadMemberQuery & { with_member: true },
options?: Pick<RequestData, 'auth' | 'signal'>,
): Promise<Required<Pick<RESTGetAPIChannelThreadMemberResult, 'member'>> & RESTGetAPIChannelThreadMemberResult>;
/**
* Fetches a member of a thread
*
* @see {@link https://discord.com/developers/docs/resources/channel#get-thread-member}
* @param threadId - The id of the thread to fetch the member from
* @param userId - The id of the user
* @param query - The query for fetching the member
* @param options - The options for fetching the member
*/
public async getMember(
threadId: Snowflake,
userId: Snowflake,
query?: RESTGetAPIChannelThreadMemberQuery,
options?: Pick<RequestData, 'auth' | 'signal'>,
): Promise<RESTGetAPIChannelThreadMemberResult>;
public async getMember(
threadId: Snowflake,
userId: Snowflake,
query: RESTGetAPIChannelThreadMemberQuery = {},
{ auth, signal }: Pick<RequestData, 'auth' | 'signal'> = {},
) {
return this.rest.get(Routes.threadMembers(threadId, userId), { auth, signal }) as Promise<APIThreadMember>;
return this.rest.get(Routes.threadMembers(threadId, userId), {
auth,
signal,
query: makeURLSearchParams(query),
});
}
/**

View File

@@ -10,9 +10,9 @@ import {
type RESTGetAPICurrentUserResult,
type RESTGetAPIUserResult,
type RESTGetCurrentUserGuildMemberResult,
type RESTPatchAPICurrentGuildMemberJSONBody,
type RESTPatchAPICurrentUserJSONBody,
type RESTPatchAPICurrentUserResult,
type RESTPatchAPIGuildMemberJSONBody,
type RESTPatchAPIGuildMemberResult,
type RESTPostAPICurrentUserCreateDMChannelResult,
type RESTPutAPICurrentUserApplicationRoleConnectionJSONBody,
@@ -111,7 +111,7 @@ export class UsersAPI {
*/
public async editCurrentGuildMember(
guildId: Snowflake,
body: RESTPatchAPIGuildMemberJSONBody = {},
body: RESTPatchAPICurrentGuildMemberJSONBody = {},
{ auth, reason, signal }: Pick<RequestData, 'auth' | 'reason' | 'signal'> = {},
) {
return this.rest.patch(Routes.guildMember(guildId, '@me'), {

View File

@@ -6,7 +6,7 @@ import { styleText } from 'node:util';
import { Option, program } from 'commander';
import prompts from 'prompts';
import validateProjectName from 'validate-npm-package-name';
import packageJSON from '../package.json' assert { type: 'json' };
import packageJSON from '../package.json' with { type: 'json' };
import { createDiscordBot } from '../src/create-discord-bot.js';
import { resolvePackageManager } from '../src/helpers/packageManager.js';
import { DEFAULT_PROJECT_NAME, PACKAGE_MANAGERS } from '../src/util/constants.js';

View File

@@ -50,25 +50,25 @@
"homepage": "https://discord.js.org",
"funding": "https://github.com/discordjs/discord.js?sponsor",
"dependencies": {
"commander": "^14.0.0",
"commander": "^14.0.1",
"prompts": "^2.4.2",
"validate-npm-package-name": "^6.0.1"
"validate-npm-package-name": "^6.0.2"
},
"devDependencies": {
"@discordjs/api-extractor": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@types/node": "^22.16.3",
"@types/node": "^22.18.8",
"@types/prompts": "^2.4.9",
"@types/validate-npm-package-name": "^4.0.2",
"cross-env": "^7.0.3",
"eslint": "^9.30.1",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"terser": "^5.43.1",
"terser": "^5.44.0",
"tsup": "^8.5.0",
"typescript": "~5.8.3"
"typescript": "~5.9.3"
},
"engines": {
"node": ">=22.12.0"

View File

@@ -14,6 +14,11 @@ export type PackageManager = 'bun' | 'deno' | 'npm' | 'pnpm' | 'yarn';
export function resolvePackageManager(): PackageManager {
const npmConfigUserAgent = process.env.npm_config_user_agent;
// @ts-expect-error: We're not using Deno's types, so its global is not declared
if (typeof Deno !== 'undefined') {
return 'deno';
}
// If this is not present, return the default package manager.
if (!npmConfigUserAgent) {
return DEFAULT_PACKAGE_MANAGER;

View File

@@ -11,15 +11,15 @@
"start": "bun run src/index.[REPLACE_IMPORT_EXT]"
},
"dependencies": {
"@discordjs/core": "^2.2.0",
"discord.js": "^14.21.0"
"@discordjs/core": "^2.2.2",
"discord.js": "^14.22.1"
},
"devDependencies": {
"eslint": "^9.30.1",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"zod": "^3.25.74"
"zod": "^3.25.76"
}
}

View File

@@ -11,18 +11,18 @@
"start": "bun run src/index.[REPLACE_IMPORT_EXT]"
},
"dependencies": {
"@discordjs/core": "^2.2.0",
"discord.js": "^14.21.0"
"@discordjs/core": "^2.2.2",
"discord.js": "^14.22.1"
},
"devDependencies": {
"@sapphire/ts-config": "^5.0.1",
"@types/bun": "^1.2.18",
"eslint": "^9.30.1",
"@types/bun": "^1.2.23",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"typescript": "~5.8.3",
"zod": "^3.25.74"
"typescript": "~5.9.3",
"zod": "^3.25.76"
}
}

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://json.schemastore.org/prettierrc.json",
"printWidth": 120,
"useTabs": true,
"singleQuote": true,
"quoteProps": "as-needed",
"trailingComma": "all",
"endOfLine": "lf"
}

View File

@@ -1,3 +1,3 @@
{
"recommendations": ["denoland.vscode-deno", "tamasfe.even-better-toml", "codezombiech.gitignore"]
"recommendations": ["denoland.vscode-deno"]
}

View File

@@ -2,14 +2,11 @@
"editor.defaultFormatter": "denoland.vscode-deno",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true,
"source.organizeImports": false
"source.fixAll": "always",
"source.organizeImports": "always"
},
"editor.trimAutoWhitespace": false,
"files.insertFinalNewline": true,
"files.eol": "\n",
"npm.packageManager": "[REPLACE_ME]",
"deno.enable": "[REPLACE_BOOL]",
"deno.lint": "[REPLACE_BOOL]",
"deno.unstable": false
"deno.enable": "[REPLACE_BOOL]"
}

View File

@@ -2,40 +2,25 @@
"$schema": "https://raw.githubusercontent.com/denoland/deno/main/cli/schemas/config-file.v1.json",
"tasks": {
"lint": "deno lint",
"deploy": "deno run --allow-read --allow-env --allow-net src/util/deploy.ts",
"deploy": "deno run --env-file --allow-read --allow-env --allow-net src/util/deploy.ts",
"format": "deno fmt",
"fmt": "deno fmt",
"start": "deno run --allow-read --allow-env --allow-net src/index.ts",
"start": "deno run --env-file --allow-read --allow-env --allow-net src/index.ts",
},
"fmt": {
"useTabs": true,
"lineWidth": 120,
"singleQuote": true,
},
"lint": {
"include": ["src/"],
"rules": {
"tags": ["recommended"],
"exclude": ["require-await", "no-await-in-sync-fn"],
},
},
"fmt": {
"useTabs": true,
"lineWidth": 120,
"semiColons": true,
"singleQuote": true,
"proseWrap": "preserve",
"include": ["src/"],
},
"compilerOptions": {
"alwaysStrict": true,
"emitDecoratorMetadata": true,
"verbatimModuleSyntax": true,
"lib": ["deno.window"],
"noFallthroughCasesInSwitch": true,
"noImplicitReturns": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"removeComments": false,
"strict": true,
"allowUnreachableCode": false,
"allowUnusedLabels": false,
"exactOptionalPropertyTypes": false,
"noImplicitOverride": true,
"imports": {
"@discordjs/core": "npm:@discordjs/core@^2.2.2",
"discord.js": "npm:discord.js@^14.22.1",
"zod": "npm:zod@^3.25.76",
},
}

View File

@@ -1,5 +1,5 @@
import type { RESTPostAPIApplicationCommandsJSONBody, CommandInteraction } from 'npm:discord.js@^14.20.0';
import { z } from 'npm:zod@^3.24.1';
import type { CommandInteraction, RESTPostAPIApplicationCommandsJSONBody } from 'discord.js';
import { z } from 'zod';
import type { StructurePredicate } from '../util/loaders.ts';
/**

View File

@@ -1,21 +1,21 @@
import type { ClientEvents } from 'npm:discord.js@^14.20.0';
import { z } from 'npm:zod@^3.24.1';
import type { ClientEvents } from 'discord.js';
import { z } from 'zod';
import type { StructurePredicate } from '../util/loaders.ts';
/**
* Defines the structure of an event.
*/
export type Event<T extends keyof ClientEvents = keyof ClientEvents> = {
export type Event<EventName extends keyof ClientEvents = keyof ClientEvents> = {
/**
* The function to execute when the event is emitted.
*
* @param parameters - The parameters of the event
*/
execute(...parameters: ClientEvents[T]): Promise<void> | void;
execute(...parameters: ClientEvents[EventName]): Promise<void> | void;
/**
* The name of the event to listen to
*/
name: T;
name: EventName;
/**
* Whether or not the event should only be listened to once
*

View File

@@ -1,4 +1,4 @@
import { Events } from 'npm:discord.js@^14.20.0';
import { Events } from 'discord.js';
import type { Event } from './index.ts';
import { loadCommands } from '../util/loaders.ts';

View File

@@ -1,4 +1,4 @@
import { Events } from 'npm:discord.js@^14.20.0';
import { Events } from 'discord.js';
import type { Event } from './index.ts';
export default {

View File

@@ -1,6 +1,4 @@
import 'https://deno.land/std@0.223.0/dotenv/load.ts';
import { URL } from 'node:url';
import { Client, GatewayIntentBits } from 'npm:discord.js@^14.20.0';
import { Client, GatewayIntentBits } from 'discord.js';
import { loadEvents } from './util/loaders.ts';
// Initialize the client

View File

@@ -1,7 +1,5 @@
import 'https://deno.land/std@0.223.0/dotenv/load.ts';
import { URL } from 'node:url';
import { API } from 'npm:@discordjs/core@^2.1.1/http-only';
import { REST } from 'npm:discord.js@^14.20.0';
import { API } from '@discordjs/core/http-only';
import { REST } from 'discord.js';
import { loadCommands } from './loaders.ts';
const commands = await loadCommands(new URL('../commands/', import.meta.url));

View File

@@ -1,6 +1,7 @@
import type { PathLike } from 'node:fs';
import { readdir, stat } from 'node:fs/promises';
import { URL } from 'node:url';
import { glob, stat } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Command } from '../commands/index.ts';
import { predicate as commandPredicate } from '../commands/index.ts';
import type { Event } from '../events/index.ts';
@@ -9,7 +10,7 @@ import { predicate as eventPredicate } from '../events/index.ts';
/**
* A predicate to check if the structure is valid
*/
export type StructurePredicate<T> = (structure: unknown) => structure is T;
export type StructurePredicate<Structure> = (structure: unknown) => structure is Structure;
/**
* Loads all the structures in the provided directory
@@ -19,11 +20,11 @@ export type StructurePredicate<T> = (structure: unknown) => structure is T;
* @param recursive - Whether to recursively load the structures in the directory
* @returns
*/
export async function loadStructures<T>(
export async function loadStructures<Structure>(
dir: PathLike,
predicate: StructurePredicate<T>,
predicate: StructurePredicate<Structure>,
recursive = true,
): Promise<T[]> {
): Promise<Structure[]> {
// Get the stats of the directory
const statDir = await stat(dir);
@@ -32,34 +33,24 @@ export async function loadStructures<T>(
throw new Error(`The directory '${dir}' is not a directory.`);
}
// Get all the files in the directory
const files = await readdir(dir);
// Create an empty array to store the structures
const structures: T[] = [];
const structures: Structure[] = [];
// Loop through all the files in the directory
for (const file of files) {
const fileUrl = new URL(`${dir}/${file}`, import.meta.url);
// Create a glob pattern to match the .ts files
const basePath = dir instanceof URL ? fileURLToPath(dir) : dir.toString();
const pattern = resolve(basePath, recursive ? '**/*.ts' : '*.ts');
// Get the stats of the file
const statFile = await stat(fileUrl);
// If the file is a directory and recursive is true, recursively load the structures in the directory
if (statFile.isDirectory() && recursive) {
structures.push(...(await loadStructures(fileUrl, predicate, recursive)));
continue;
}
// If the file is index.ts or the file does not end with .ts, skip the file
if (file === 'index.ts' || !file.endsWith('.ts')) {
// Loop through all the matching files in the directory
for await (const file of glob(pattern)) {
// If the file is index.ts, skip the file
if (file.endsWith('/index.ts')) {
continue;
}
// Import the structure dynamically from the file
const structure = (await import(fileUrl.toString())).default;
const { default: structure } = await import(file);
// If the structure is a valid structure, add it
// If the default export is a valid structure, add it
if (predicate(structure)) {
structures.push(structure);
}

View File

@@ -11,16 +11,16 @@
"deploy": "node --env-file=.env src/util/deploy.js"
},
"dependencies": {
"@discordjs/core": "^2.2.0",
"discord.js": "^14.21.0"
"@discordjs/core": "^2.2.2",
"discord.js": "^14.22.1"
},
"devDependencies": {
"eslint": "^9.30.1",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"zod": "^3.25.74"
"zod": "^3.25.76"
},
"engines": {
"node": ">=22.12.0"

View File

@@ -3,10 +3,10 @@ import { z } from 'zod';
/**
* Defines the structure of an event.
*
* @template {keyof import('discord.js').ClientEvents} [T=keyof import('discord.js').ClientEvents]
* @template {keyof import('discord.js').ClientEvents} [EventName=keyof import('discord.js').ClientEvents]
* @typedef {object} Event
* @property {(...parameters: import('discord.js').ClientEvents[T]) => Promise<void> | void} execute The function to execute the command
* @property {T} name The name of the event to listen to
* @property {(...parameters: import('discord.js').ClientEvents[EventName]) => Promise<void> | void} execute The function to execute the command
* @property {EventName} name The name of the event to listen to
* @property {boolean} [once] Whether or not the event should only be listened to once
*/

View File

@@ -1,23 +1,23 @@
import { readdir, stat } from 'node:fs/promises';
import { URL } from 'node:url';
import { glob, stat } from 'node:fs/promises';
import { fileURLToPath, resolve, URL } from 'node:url';
import { predicate as commandPredicate } from '../commands/index.js';
import { predicate as eventPredicate } from '../events/index.js';
/**
* A predicate to check if the structure is valid.
*
* @template T
* @typedef {(structure: unknown) => structure is T} StructurePredicate
* @template Structure
* @typedef {(structure: unknown) => structure is Structure} StructurePredicate
*/
/**
* Loads all the structures in the provided directory.
*
* @template T
* @template Structure
* @param {import('node:fs').PathLike} dir - The directory to load the structures from
* @param {StructurePredicate<T>} predicate - The predicate to check if the structure is valid
* @param {StructurePredicate<Structure>} predicate - The predicate to check if the structure is valid
* @param {boolean} recursive - Whether to recursively load the structures in the directory
* @returns {Promise<T[]>}
* @returns {Promise<Structure[]>}
*/
export async function loadStructures(dir, predicate, recursive = true) {
// Get the stats of the directory
@@ -28,35 +28,25 @@ export async function loadStructures(dir, predicate, recursive = true) {
throw new Error(`The directory '${dir}' is not a directory.`);
}
// Get all the files in the directory
const files = await readdir(dir);
// Create an empty array to store the structures
/** @type {T[]} */
/** @type {Structure[]} */
const structures = [];
// Loop through all the files in the directory
for (const file of files) {
const fileUrl = new URL(`${dir}/${file}`, import.meta.url);
// Create a glob pattern to match the .js files
const basePath = dir instanceof URL ? fileURLToPath(dir) : dir.toString();
const pattern = resolve(basePath, recursive ? '**/*.js' : '*.js');
// Get the stats of the file
const statFile = await stat(fileUrl);
// If the file is a directory and recursive is true, recursively load the structures in the directory
if (statFile.isDirectory() && recursive) {
structures.push(...(await loadStructures(fileUrl, predicate, recursive)));
continue;
}
// If the file is index.js or the file does not end with .js, skip the file
if (file === 'index.js' || !file.endsWith('.js')) {
// Loop through all the matching files in the directory
for await (const file of glob(pattern)) {
// If the file is index.js, skip the file
if (file.endsWith('/index.js')) {
continue;
}
// Import the structure dynamically from the file
const structure = (await import(fileUrl.toString())).default;
const { default: structure } = await import(file);
// If the structure is a valid structure, add it
// If the default export is a valid structure, add it
if (predicate(structure)) {
structures.push(structure);
}
@@ -68,7 +58,7 @@ export async function loadStructures(dir, predicate, recursive = true) {
/**
* @param {import('node:fs').PathLike} dir
* @param {boolean} [recursive]
* @returns {Promise<Map<string,import('../commands/index.js').Command>>}
* @returns {Promise<Map<string, import('../commands/index.js').Command>>}
*/
export async function loadCommands(dir, recursive = true) {
return (await loadStructures(dir, commandPredicate, recursive)).reduce(

View File

@@ -3,8 +3,8 @@
"editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true,
"editor.codeActionsOnSave": {
"source.fixAll": true,
"source.organizeImports": false
"source.fixAll": "explicit",
"source.organizeImports": "never"
},
"editor.trimAutoWhitespace": false,
"files.insertFinalNewline": true,

View File

@@ -12,19 +12,19 @@
"start": "node --env-file=.env dist/index.js"
},
"dependencies": {
"@discordjs/core": "^2.2.0",
"discord.js": "^14.21.0"
"@discordjs/core": "^2.2.2",
"discord.js": "^14.22.1"
},
"devDependencies": {
"@sapphire/ts-config": "^5.0.1",
"@types/node": "^22.16.0",
"eslint": "^9.30.1",
"@types/node": "^22.18.8",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"prettier": "^3.6.2",
"typescript": "~5.8.3",
"zod": "^3.25.74"
"typescript": "~5.9.3",
"zod": "^3.25.76"
},
"engines": {
"node": ">=22.12.0"

View File

@@ -5,17 +5,17 @@ import type { StructurePredicate } from '../util/loaders.[REPLACE_IMPORT_EXT]';
/**
* Defines the structure of an event.
*/
export type Event<T extends keyof ClientEvents = keyof ClientEvents> = {
export type Event<EventName extends keyof ClientEvents = keyof ClientEvents> = {
/**
* The function to execute when the event is emitted.
*
* @param parameters - The parameters of the event
*/
execute(...parameters: ClientEvents[T]): Promise<void> | void;
execute(...parameters: ClientEvents[EventName]): Promise<void> | void;
/**
* The name of the event to listen to
*/
name: T;
name: EventName;
/**
* Whether or not the event should only be listened to once
*

View File

@@ -1,6 +1,7 @@
import type { PathLike } from 'node:fs';
import { readdir, stat } from 'node:fs/promises';
import { URL } from 'node:url';
import { glob, stat } from 'node:fs/promises';
import { resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Command } from '../commands/index.[REPLACE_IMPORT_EXT]';
import { predicate as commandPredicate } from '../commands/index.[REPLACE_IMPORT_EXT]';
import type { Event } from '../events/index.[REPLACE_IMPORT_EXT]';
@@ -9,7 +10,7 @@ import { predicate as eventPredicate } from '../events/index.[REPLACE_IMPORT_EXT
/**
* A predicate to check if the structure is valid
*/
export type StructurePredicate<T> = (structure: unknown) => structure is T;
export type StructurePredicate<Structure> = (structure: unknown) => structure is Structure;
/**
* Loads all the structures in the provided directory
@@ -19,11 +20,11 @@ export type StructurePredicate<T> = (structure: unknown) => structure is T;
* @param recursive - Whether to recursively load the structures in the directory
* @returns
*/
export async function loadStructures<T>(
export async function loadStructures<Structure>(
dir: PathLike,
predicate: StructurePredicate<T>,
predicate: StructurePredicate<Structure>,
recursive = true,
): Promise<T[]> {
): Promise<Structure[]> {
// Get the stats of the directory
const statDir = await stat(dir);
@@ -32,35 +33,27 @@ export async function loadStructures<T>(
throw new Error(`The directory '${dir}' is not a directory.`);
}
// Get all the files in the directory
const files = await readdir(dir);
// Create an empty array to store the structures
const structures: T[] = [];
const structures: Structure[] = [];
// Loop through all the files in the directory
for (const file of files) {
const fileUrl = new URL(`${dir}/${file}`, import.meta.url);
// Create a glob pattern to match the .[REPLACE_IMPORT_EXT] files
const basePath = dir instanceof URL ? fileURLToPath(dir) : dir.toString();
const pattern = resolve(basePath, recursive ? '**/*.[REPLACE_IMPORT_EXT]' : '*.[REPLACE_IMPORT_EXT]');
// Get the stats of the file
const statFile = await stat(fileUrl);
// If the file is a directory and recursive is true, recursively load the structures in the directory
if (statFile.isDirectory() && recursive) {
structures.push(...(await loadStructures(fileUrl, predicate, recursive)));
continue;
}
// If the file is index.[REPLACE_IMPORT_EXT] or the file does not end with .[REPLACE_IMPORT_EXT], skip the file
if (file === 'index.[REPLACE_IMPORT_EXT]' || !file.endsWith('.[REPLACE_IMPORT_EXT]')) {
// Loop through all the matching files in the directory
for await (const file of glob(pattern)) {
// If the file is index.[REPLACE_IMPORT_EXT], skip the file
if (file.endsWith('/index.[REPLACE_IMPORT_EXT]')) {
continue;
}
// Import the structure dynamically from the file
const structure = (await import(fileUrl.toString())).default;
const { default: structure } = await import(file);
// If the structure is a valid structure, add it
if (predicate(structure)) structures.push(structure);
// If the default export is a valid structure, add it
if (predicate(structure)) {
structures.push(structure);
}
}
return structures;

View File

@@ -73,31 +73,31 @@
"@discordjs/util": "workspace:^",
"@discordjs/ws": "workspace:^",
"@sapphire/snowflake": "3.5.5",
"@vladfrangu/async_event_emitter": "^2.4.6",
"discord-api-types": "^0.38.16",
"@vladfrangu/async_event_emitter": "^2.4.7",
"discord-api-types": "^0.38.29",
"fast-deep-equal": "3.1.3",
"lodash.snakecase": "4.1.1",
"magic-bytes.js": "^1.12.1",
"tslib": "^2.8.1",
"undici": "7.11.0"
"undici": "7.16.0"
},
"devDependencies": {
"@discordjs/api-extractor": "workspace:^",
"@discordjs/docgen": "workspace:^",
"@discordjs/scripts": "workspace:^",
"@favware/cliff-jumper": "^4.1.0",
"@types/node": "^22.16.3",
"cross-env": "^7.0.3",
"eslint": "^9.30.1",
"@types/node": "^22.18.8",
"cross-env": "^10.1.0",
"eslint": "^9.37.0",
"eslint-config-neon": "^0.2.7",
"eslint-formatter-compact": "^8.40.0",
"eslint-formatter-pretty": "^6.0.1",
"eslint-formatter-pretty": "^7.0.0",
"eslint-plugin-import": "^2.32.0",
"eslint-plugin-jsdoc": "^51.3.4",
"eslint-plugin-jsdoc": "^54.7.0",
"prettier": "^3.6.2",
"tsd": "^0.32.0",
"turbo": "^2.5.4",
"typescript": "~5.8.3"
"tsd": "^0.33.0",
"turbo": "^2.5.8",
"typescript": "~5.9.3"
},
"engines": {
"node": ">=22.12.0"

View File

@@ -39,12 +39,12 @@ async function writeClientActionImports() {
const actionName = file.slice(0, -3);
lines.push(` this.register(require('./${file}').${actionName}Action);`);
lines.push(` this.${actionName} = this.load(require('./${file}').${actionName}Action);`);
}
lines.push(' }\n');
lines.push(' register(Action) {');
lines.push(" this[Action.name.replace(/Action$/, '')] = new Action(this.client);");
lines.push(' load(Action) {');
lines.push(' return new Action(this.client);');
lines.push(' }');
lines.push('}\n');
lines.push('exports.ActionsManager = ActionsManager;\n');

View File

@@ -13,50 +13,62 @@ class ActionsManager {
constructor(client) {
this.client = client;
this.register(require('./ChannelCreate.js').ChannelCreateAction);
this.register(require('./ChannelDelete.js').ChannelDeleteAction);
this.register(require('./ChannelUpdate.js').ChannelUpdateAction);
this.register(require('./GuildChannelsPositionUpdate.js').GuildChannelsPositionUpdateAction);
this.register(require('./GuildEmojiCreate.js').GuildEmojiCreateAction);
this.register(require('./GuildEmojiDelete.js').GuildEmojiDeleteAction);
this.register(require('./GuildEmojiUpdate.js').GuildEmojiUpdateAction);
this.register(require('./GuildEmojisUpdate.js').GuildEmojisUpdateAction);
this.register(require('./GuildMemberRemove.js').GuildMemberRemoveAction);
this.register(require('./GuildMemberUpdate.js').GuildMemberUpdateAction);
this.register(require('./GuildRoleCreate.js').GuildRoleCreateAction);
this.register(require('./GuildRoleDelete.js').GuildRoleDeleteAction);
this.register(require('./GuildRolesPositionUpdate.js').GuildRolesPositionUpdateAction);
this.register(require('./GuildScheduledEventDelete.js').GuildScheduledEventDeleteAction);
this.register(require('./GuildScheduledEventUserAdd.js').GuildScheduledEventUserAddAction);
this.register(require('./GuildScheduledEventUserRemove.js').GuildScheduledEventUserRemoveAction);
this.register(require('./GuildSoundboardSoundDelete.js').GuildSoundboardSoundDeleteAction);
this.register(require('./GuildStickerCreate.js').GuildStickerCreateAction);
this.register(require('./GuildStickerDelete.js').GuildStickerDeleteAction);
this.register(require('./GuildStickerUpdate.js').GuildStickerUpdateAction);
this.register(require('./GuildStickersUpdate.js').GuildStickersUpdateAction);
this.register(require('./GuildUpdate.js').GuildUpdateAction);
this.register(require('./InteractionCreate.js').InteractionCreateAction);
this.register(require('./MessageCreate.js').MessageCreateAction);
this.register(require('./MessageDelete.js').MessageDeleteAction);
this.register(require('./MessageDeleteBulk.js').MessageDeleteBulkAction);
this.register(require('./MessagePollVoteAdd.js').MessagePollVoteAddAction);
this.register(require('./MessagePollVoteRemove.js').MessagePollVoteRemoveAction);
this.register(require('./MessageReactionAdd.js').MessageReactionAddAction);
this.register(require('./MessageReactionRemove.js').MessageReactionRemoveAction);
this.register(require('./MessageReactionRemoveAll.js').MessageReactionRemoveAllAction);
this.register(require('./MessageReactionRemoveEmoji.js').MessageReactionRemoveEmojiAction);
this.register(require('./MessageUpdate.js').MessageUpdateAction);
this.register(require('./StageInstanceCreate.js').StageInstanceCreateAction);
this.register(require('./StageInstanceDelete.js').StageInstanceDeleteAction);
this.register(require('./StageInstanceUpdate.js').StageInstanceUpdateAction);
this.register(require('./ThreadCreate.js').ThreadCreateAction);
this.register(require('./ThreadMembersUpdate.js').ThreadMembersUpdateAction);
this.register(require('./TypingStart.js').TypingStartAction);
this.register(require('./UserUpdate.js').UserUpdateAction);
this.ChannelCreate = this.load(require('./ChannelCreate.js').ChannelCreateAction);
this.ChannelDelete = this.load(require('./ChannelDelete.js').ChannelDeleteAction);
this.ChannelUpdate = this.load(require('./ChannelUpdate.js').ChannelUpdateAction);
this.GuildChannelsPositionUpdate = this.load(
require('./GuildChannelsPositionUpdate.js').GuildChannelsPositionUpdateAction,
);
this.GuildEmojiCreate = this.load(require('./GuildEmojiCreate.js').GuildEmojiCreateAction);
this.GuildEmojiDelete = this.load(require('./GuildEmojiDelete.js').GuildEmojiDeleteAction);
this.GuildEmojiUpdate = this.load(require('./GuildEmojiUpdate.js').GuildEmojiUpdateAction);
this.GuildEmojisUpdate = this.load(require('./GuildEmojisUpdate.js').GuildEmojisUpdateAction);
this.GuildMemberRemove = this.load(require('./GuildMemberRemove.js').GuildMemberRemoveAction);
this.GuildMemberUpdate = this.load(require('./GuildMemberUpdate.js').GuildMemberUpdateAction);
this.GuildRoleCreate = this.load(require('./GuildRoleCreate.js').GuildRoleCreateAction);
this.GuildRoleDelete = this.load(require('./GuildRoleDelete.js').GuildRoleDeleteAction);
this.GuildRolesPositionUpdate = this.load(require('./GuildRolesPositionUpdate.js').GuildRolesPositionUpdateAction);
this.GuildScheduledEventDelete = this.load(
require('./GuildScheduledEventDelete.js').GuildScheduledEventDeleteAction,
);
this.GuildScheduledEventUserAdd = this.load(
require('./GuildScheduledEventUserAdd.js').GuildScheduledEventUserAddAction,
);
this.GuildScheduledEventUserRemove = this.load(
require('./GuildScheduledEventUserRemove.js').GuildScheduledEventUserRemoveAction,
);
this.GuildSoundboardSoundDelete = this.load(
require('./GuildSoundboardSoundDelete.js').GuildSoundboardSoundDeleteAction,
);
this.GuildStickerCreate = this.load(require('./GuildStickerCreate.js').GuildStickerCreateAction);
this.GuildStickerDelete = this.load(require('./GuildStickerDelete.js').GuildStickerDeleteAction);
this.GuildStickerUpdate = this.load(require('./GuildStickerUpdate.js').GuildStickerUpdateAction);
this.GuildStickersUpdate = this.load(require('./GuildStickersUpdate.js').GuildStickersUpdateAction);
this.GuildUpdate = this.load(require('./GuildUpdate.js').GuildUpdateAction);
this.InteractionCreate = this.load(require('./InteractionCreate.js').InteractionCreateAction);
this.MessageCreate = this.load(require('./MessageCreate.js').MessageCreateAction);
this.MessageDelete = this.load(require('./MessageDelete.js').MessageDeleteAction);
this.MessageDeleteBulk = this.load(require('./MessageDeleteBulk.js').MessageDeleteBulkAction);
this.MessagePollVoteAdd = this.load(require('./MessagePollVoteAdd.js').MessagePollVoteAddAction);
this.MessagePollVoteRemove = this.load(require('./MessagePollVoteRemove.js').MessagePollVoteRemoveAction);
this.MessageReactionAdd = this.load(require('./MessageReactionAdd.js').MessageReactionAddAction);
this.MessageReactionRemove = this.load(require('./MessageReactionRemove.js').MessageReactionRemoveAction);
this.MessageReactionRemoveAll = this.load(require('./MessageReactionRemoveAll.js').MessageReactionRemoveAllAction);
this.MessageReactionRemoveEmoji = this.load(
require('./MessageReactionRemoveEmoji.js').MessageReactionRemoveEmojiAction,
);
this.MessageUpdate = this.load(require('./MessageUpdate.js').MessageUpdateAction);
this.StageInstanceCreate = this.load(require('./StageInstanceCreate.js').StageInstanceCreateAction);
this.StageInstanceDelete = this.load(require('./StageInstanceDelete.js').StageInstanceDeleteAction);
this.StageInstanceUpdate = this.load(require('./StageInstanceUpdate.js').StageInstanceUpdateAction);
this.ThreadCreate = this.load(require('./ThreadCreate.js').ThreadCreateAction);
this.ThreadMembersUpdate = this.load(require('./ThreadMembersUpdate.js').ThreadMembersUpdateAction);
this.TypingStart = this.load(require('./TypingStart.js').TypingStartAction);
this.UserUpdate = this.load(require('./UserUpdate.js').UserUpdateAction);
}
register(Action) {
this[Action.name.replace(/Action$/, '')] = new Action(this.client);
load(Action) {
return new Action(this.client);
}
}

View File

@@ -114,8 +114,10 @@
* @property {'CommandInteractionOptionInvalidChannelType'} CommandInteractionOptionInvalidChannelType
* @property {'AutocompleteInteractionOptionNoFocusedOption'} AutocompleteInteractionOptionNoFocusedOption
*
* @property {'ModalSubmitInteractionFieldNotFound'} ModalSubmitInteractionFieldNotFound
* @property {'ModalSubmitInteractionFieldType'} ModalSubmitInteractionFieldType
* @property {'ModalSubmitInteractionComponentNotFound'} ModalSubmitInteractionComponentNotFound
* @property {'ModalSubmitInteractionComponentType'} ModalSubmitInteractionComponentType
* @property {'ModalSubmitInteractionComponentEmpty'} ModalSubmitInteractionComponentEmpty
* @property {'ModalSubmitInteractionComponentInvalidChannelType'} ModalSubmitInteractionComponentInvalidChannelType
*
* @property {'InvalidMissingScopes'} InvalidMissingScopes
* @property {'InvalidScopesWithPermissions'} InvalidScopesWithPermissions
@@ -248,8 +250,10 @@ const keys = [
'CommandInteractionOptionInvalidChannelType',
'AutocompleteInteractionOptionNoFocusedOption',
'ModalSubmitInteractionFieldNotFound',
'ModalSubmitInteractionFieldType',
'ModalSubmitInteractionComponentNotFound',
'ModalSubmitInteractionComponentType',
'ModalSubmitInteractionComponentEmpty',
'ModalSubmitInteractionComponentInvalidChannelType',
'InvalidMissingScopes',
'InvalidScopesWithPermissions',

View File

@@ -127,10 +127,14 @@ const Messages = {
`The type of channel of the option "${name}" is: ${type}; expected ${expected}.`,
[ErrorCodes.AutocompleteInteractionOptionNoFocusedOption]: 'No focused option for autocomplete interaction.',
[ErrorCodes.ModalSubmitInteractionFieldNotFound]: customId =>
`Required field with custom id "${customId}" not found.`,
[ErrorCodes.ModalSubmitInteractionFieldType]: (customId, type, expected) =>
`Field with custom id "${customId}" is of type: ${type}; expected ${expected}.`,
[ErrorCodes.ModalSubmitInteractionComponentNotFound]: customId =>
`Required component with custom id "${customId}" not found.`,
[ErrorCodes.ModalSubmitInteractionComponentType]: (customId, type, expected) =>
`Component with custom id "${customId}" is of type: ${type}; expected ${expected}.`,
[ErrorCodes.ModalSubmitInteractionComponentEmpty]: (customId, type) =>
`Required component with custom id "${customId}" is of type: ${type}; expected a non-empty value.`,
[ErrorCodes.ModalSubmitInteractionComponentInvalidChannelType]: (customId, type, expected) =>
`The type of channel of the component with custom id "${customId}" is: ${type}; expected ${expected}.`,
[ErrorCodes.InvalidMissingScopes]: 'At least one valid scope must be provided for the invite',
[ErrorCodes.InvalidScopesWithPermissions]: 'Permissions cannot be set without the bot scope.',

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