Skip to content

Bring the library up to the modern Discord API - #133

Open
mikield wants to merge 13 commits into
dc-Ragnarok:masterfrom
mikield:feat/modern-discord-api
Open

Bring the library up to the modern Discord API#133
mikield wants to merge 13 commits into
dc-Ragnarok:masterfrom
mikield:feat/modern-discord-api

Conversation

@mikield

@mikield mikield commented Aug 22, 2026

Copy link
Copy Markdown

Summary

Fenrir covers the classic Discord API well, but hasn't tracked what Discord shipped after roughly 2023. This closes that distance, in twelve self-contained commits.

I audited the library against the current API first, then worked through the gaps. composer test, composer cs and util/verify-namespacing.sh are green at every commit, and the suite goes 526 → 635 tests.

Happy to split this into separate PRs, drop anything you'd rather not carry, or adjust naming to taste — I followed the conventions already here rather than introduce my own.

Commits

Commit What it does
Complete the application command endpoints 6 of 14 → all 14
Add the poll gateway intents GUILD_MESSAGE_POLLS, DIRECT_MESSAGE_POLLS
Add soundboard support 7 endpoints, 5 gateway events, from nothing
Add poll endpoints and let messages carry a poll 2 endpoints + PollBuilder
Add entitlements 5 endpoints, 3 gateway events
Add the guild audit log entry create event 1 event
Add SKUs and subscriptions 3 endpoints, 3 gateway events
Make select menus and modal submissions readable two bug fixes, two new interaction wrappers
Add components v2 7 message components
Add modal building ModalBuilder + 5 modal components
Add guild onboarding and the remaining guild endpoints onboarding, bulk ban, welcome screen write, role member counts
Add the remaining channel endpoints, and the pins Discord actually uses current pins, voice status, thread search

Gateway events go 58 → 70 of the 75 Discord documents. Message component types go 8 → 20.

Two bugs worth calling out

Select menu values were unreadable. InteractionData::$values was typed ComponentSelectOptions[] and mapped through ArrayMapping, but Discord sends plain strings there. So this:

$mapper->map((object) ['values' => ['red', 'green']], InteractionData::class);

produced two empty ComponentSelectOptions objects — the user's selection was discarded entirely. There was no way to read what someone picked from a select menu. Now string[].

Modal submissions were unreachable. Nothing consumed MODAL_SUBMIT, and InteractionData had no components field to read one from. Added, along with a ModalSubmitInteraction that walks the payload collecting anything with a custom_id and a value — deliberately not assuming a fixed depth, since Discord nests inputs in action rows for classic modals and in labels for the newer ones, so it keeps working across both.

Along the way component_type became a MessageComponentType, which resolves the // @todo enum on it and the matching one on the button filter in InteractionHandler.

Pins

Discord moved pins from /channels/{id}/pins to /channels/{id}/messages/pins — the new ones paginate and report when each message was pinned. discord-php/http already marks the old constants deprecated. The three existing methods stay, marked @deprecated, so nothing breaks; getChannelPins, pinChannelMessage and unpinChannelMessage cover the current endpoints.

Bulk overwrite

Probably the most useful single addition. Registering commands by POSTing them one at a time costs a request per command and never removes one deleted from the code, so stale commands linger in Discord forever. PUT replaces the set in one request and deletes anything absent from it.

A note on sourcing

The docs pages for gateway events and components are large enough that fetching them returns truncated content — and the truncation isn't always visible. Asking for the Separator structure gave me a table with the divider and spacing rows silently missing, which would have shipped a broken component.

So the Components V2 and modal structures come from Discord's published OpenAPI specification instead, which gives field names, types and limits exactly. I cross-checked the poll parts against it too, and they match.

Two things are still absent for that reason, both deliberate:

  • MESSAGE_POLL_VOTE_ADD / MESSAGE_POLL_VOTE_REMOVE. Their field tables sit past the truncation point and the OpenAPI spec is REST-only, so I couldn't verify the payload. The intents they need are in; the events should follow once someone can read the structure against the docs. Guessing seemed worse than leaving them out.
  • The three voice channel events (VOICE_CHANNEL_EFFECT_SEND, VOICE_CHANNEL_STATUS_UPDATE, VOICE_CHANNEL_START_TIME_UPDATE), same reason.

Upstream oddity

discord-php/http declares SKU_SUBSCRIPTIONS and SKU_SUBSCRIPTION with a leading slash, unlike every other endpoint constant, and Request joins the base url with a separator of its own — so the request would go to a path with a doubled slash. I trim it before binding, which stays correct if that's fixed upstream, and there's a test pinning the resulting path.

Separately, its GUILD_APPLICATION_COMMANDS_PERMISSIONS constant points at a path Discord no longer documents, so I left that one alone rather than wrap a dead endpoint.

Not included

  • Lobbies and the partner-sdk endpoints — Social SDK surface, not bot-facing.
  • Guild incident actionsdiscord-php/http has no constant for it.
  • new-member-welcome — in the OpenAPI spec but not in the resource documentation.

mikield added 13 commits August 22, 2026 04:18
Discord documents fourteen endpoints for application commands; GlobalCommand
and GuildCommand exposed three each. The eight that were missing are added,
using endpoint constants that already existed in discord-php/http.

Bulk overwrite is the notable one. Registering an application's commands by
POSTing them one at a time costs a request per command and never removes a
command that has been deleted from the code, so stale commands linger in
Discord indefinitely. PUT replaces the whole set in a single request and
deletes anything absent from it, which is what Discord recommends for
registration.

Added to both resources:
  getApplicationCommand              GET    one command
  editApplicationCommand             PATCH  one command
  bulkOverwriteApplicationCommands   PUT    the whole set

Added to GuildCommand:
  getApplicationCommandPermissions   GET    a command's permissions
  editApplicationCommandPermissions  PUT    a command's permissions

Note that Discord rejects the permissions write when authenticated with a bot
token; it requires a bearer token carrying
applications.commands.permissions.update. That is documented on the method
rather than enforced, since the resource has no view of how the client
authenticated.

There is no endpoint for reading every command's permissions in a guild at
once. discord-php/http still carries a GUILD_APPLICATION_COMMANDS_PERMISSIONS
constant for it, but Discord no longer documents that path, so it is left
alone.
GUILD_MESSAGE_POLLS (1 << 24) and DIRECT_MESSAGE_POLLS (1 << 25) are the two
intents Discord documents that the enum was missing. Every other case already
carries the correct bit.

Note that bit 3 is still named GUILD_EMOJIS_AND_STICKERS here where Discord now
calls it GUILD_EXPRESSIONS. Same bit, so renaming it is a cosmetic change that
would break anyone referencing the case by name; left alone deliberately.
Soundboard was absent entirely: no REST resource, no parts, and none of its
five gateway events.

The REST resource covers all seven documented endpoints. Two details worth
noting:

List Guild Soundboard Sounds returns an object wrapping an "items" array while
List Default Soundboard Sounds returns a bare array, so the former maps to a
GuildSoundboardSounds part in the same way ActiveGuildThreads already handles
that shape for threads.

Creating a sound takes a data URI rather than a multipart upload, so
CreateSoundboardSoundBuilder mirrors CreateEmojiBuilder. GetBase64Sound and a
SoundData enum sit alongside the existing GetBase64Image and ImageData; the
mime types are audio rather than image, so the two cannot share an enum.

The four guild events are gated behind bit 3, which Discord documents as
GUILD_EXPRESSIONS and this library still calls GUILD_EMOJIS_AND_STICKERS.
SOUNDBOARD_SOUNDS carries no intent because it arrives in response to a gateway
request rather than as a subscription.

Modify accepts explicit nulls for volume, emoji_id and emoji_name, since Discord
treats those as nullable and clearing one has to reach the payload rather than
being dropped as unset.
Polls were half supported: the Poll, PollAnswer and PollMediaObject parts
existed so an incoming message deserialized its poll, but there was no way to
send one and neither poll endpoint was implemented.

Sending: PollBuilder produces a poll create request and MessageBuilder gains
setPoll through a SetPoll trait, matching how the other message fields are
composed. The ten-answer limit Discord documents is enforced the same way
MessageBuilder already enforces the sticker limit.

Reading: the Poll REST resource covers Get Answer Voters, including its after
and limit pagination, and End Poll. Get Answer Voters returns an object
wrapping a "users" array rather than a bare array, so it maps to a
PollAnswerVoters part.

The Poll part also gains the results field, which was missing, along with the
PollResults and PollAnswerCount parts behind it. Its expiry is now nullable, as
Discord documents it.

MESSAGE_POLL_VOTE_ADD and MESSAGE_POLL_VOTE_REMOVE are deliberately not
included. The gateway events page is large enough that it truncates before
reaching their field tables, and guessing at a payload shape is worse than
leaving the events out; they should follow once the structure can be read
against the documentation.
The monetization surface was absent entirely. This adds the entitlement half:
the REST resource covering all five documented endpoints, the Entitlement part,
the EntitlementType enum, and the three gateway events.

List Entitlements takes eight query filters, so they go through a
GetEntitlementsBuilder rather than an eight-argument signature. Discord expects
sku_ids as one comma delimited value rather than a repeated parameter, which
the builder handles.

Create Test Entitlement takes an owner_type of 1 or 2. That is modelled as an
EntitlementOwnerType enum so callers do not have to remember which is which.

Entitlement events carry no intent; Discord sends them to any application that
has them.

SKUs and subscriptions are the remaining parts of monetization and are not
included here.
Sent under GUILD_MODERATION alongside the two ban events, which were already
here. The payload is an audit log entry with the guild id attached, and the
AuditLogEntry part it needs already existed.
Completes monetization alongside the entitlement resource: the SKU list
endpoint, both subscription endpoints, their parts and enums, and the three
subscription gateway events.

Subscription statuses are ACTIVE 0, INACTIVE 1, ENDING 2. That ordering reads
oddly, since ENDING describes a subscription that is still active, but it is
what the documentation gives.

discord-php/http declares SKU_SUBSCRIPTIONS and SKU_SUBSCRIPTION with a leading
slash, unlike every other endpoint constant, and Request joins the base url
with a separator of its own. Left alone the request would go to a path with a
doubled slash, so the leading one is trimmed before binding. That stays correct
if the constants are fixed upstream, and there is a test pinning the resulting
path.
Two interaction types were reachable in the enums but not usable in practice.

InteractionData typed the select menu values as ComponentSelectOptions and
mapped them through ArrayMapping. Discord sends plain strings there — option
values for a string select, ids for the user, role, mentionable and channel
selects — so mapping turned ["red", "green"] into two empty objects and the
user's selection was lost outright. They are now string[].

Nothing consumed MODAL_SUBMIT at all, and InteractionData had no components
field for a modal to be read from, so submitted values were unreachable. The
field is added and left as the raw payload deliberately: Discord nests inputs
inside action rows for classic modals and inside labels for the newer ones, so
ModalSubmitInteraction walks the tree collecting anything carrying a custom_id
and a value rather than assuming a fixed depth. That keeps it working across
both shapes.

ComponentInteraction covers buttons and select menus with getValues, getValue
and getCustomId. ButtonInteraction is untouched so nothing depending on it
breaks.

component_type becomes a MessageComponentType, which resolves the @todo on it
and on the button filter in InteractionHandler that compared it against a bare
2.
None of the components Discord introduced with the v2 layout were supported,
so bots on this library could not build modern message UI at all.

Adds the seven message components: Section, TextDisplay, Thumbnail,
MediaGallery, File, Separator and Container, with an UnfurledMedia value object
for the media references they take. Each carries the limits the schema gives —
three text displays per section, ten items per gallery, forty components per
container — and every optional field is omitted rather than sent as null so
Discord applies its own defaults.

ComponentBuilder previously held only action rows. It now holds rows and top
level components in one ordered list, since v2 lets both sit alongside each
other, and get() emits them in the order they were added. addRow, getRows and
the five row limit behave exactly as before.

MessageComponentType gains the twelve missing cases, MessageFlag gains
IS_VOICE_MESSAGE, HAS_SNAPSHOT and IS_COMPONENTS_V2, and there is a
SeparatorSpacingSize enum.

A note on sourcing: the components reference page is large enough that fetching
it returns truncated content, and the truncation is not always obvious — asking
for the Separator table yielded one with the divider and spacing rows silently
missing. These structures were taken from Discord's published OpenAPI
specification instead, which gives the field names, types and limits exactly.

The modal only components — Label, FileUpload, RadioGroup, CheckboxGroup and
Checkbox — are not included. They belong with modal building, which this
library does not have yet, and are listed in the type enum so they can be
recognised in the meantime.
The library could recognise a MODAL_SUBMIT once it arrived but had no way to
show a modal in the first place, and none of the modal only components existed.

ModalBuilder produces the callback data — custom id, title and up to forty
components. InteractionCallbackBuilder gains setModal, which also settles the
callback type, since a modal cannot be sent as any other kind of response. When
a modal is set the message oriented fields are left out of the payload
entirely; content and embeds have no meaning there and Discord rejects them.

The components are Label, FileUpload, Checkbox, CheckboxGroup and RadioGroup,
with one shared Option class since radio and checkbox options take the same
shape. Label is the important one: every interactive component in a modal sits
inside one, and it carries the text shown above the input. It accepts any
Component, so the existing text input and select menus work inside it without
needing modal specific variants.

Structures come from Discord's OpenAPI specification, as with the v2 message
components.
Onboarding was missing entirely. Modify replaces the flow wholesale rather than
patching it, so the builders mirror that: prompts and their options are built up
and sent as a set. Discord requires an id on every prompt and option even when
creating them and accepts a placeholder for those, which the builders document
rather than paper over.

Also adds four endpoints Guild was missing:

  modifyWelcomeScreen   the read side already existed, the write side did not
  bulkBan               bans up to 200 users in one request
  getRoleMemberCounts   members per role
  getOnboarding         alongside modifyOnboarding

Bulk ban reports which users it could not ban rather than failing the whole
call, so it returns a BulkBanResult carrying both lists. Role member counts
comes back as a plain map of role id to count with no documented object behind
it, so it is returned as given rather than invented into a part.

Not included: the incident actions endpoint, which discord-php/http has no
constant for, and new-member-welcome, which appears in the OpenAPI
specification but not in the resource documentation.
Discord moved pins from /channels/{id}/pins to /channels/{id}/messages/pins.
The new endpoints paginate and report when each message was pinned rather than
returning bare messages, and discord-php/http already marks the old constants
deprecated. getChannelPins, pinChannelMessage and unpinChannelMessage cover the
current endpoints; the three older methods stay, marked deprecated, so nothing
depending on them breaks.

Also adds setVoiceStatus, which sets the status line on a voice channel and
clears it when given null, and searchThreads for forum and media channels. The
latter takes eleven query parameters, so they go through a builder, with enums
for the three that are constrained: tag matching, sort field and sort order.
Bitwise::getBitSet() returns decbin(), and three payloads were sending its
result to Discord: a command's default_member_permissions, and the allow and
deny of a channel permission overwrite. Discord reads all three as decimal.

Asking for ADMINISTRATOR, which is 1 << 3, therefore sent "1000". Discord read
that as one thousand, which is ADMINISTRATOR together with MANAGE_GUILD,
ADD_REACTIONS, VIEW_AUDIT_LOG, PRIORITY_SPEAKER and STREAM. Every command
registered with default permissions has been granting five permissions nobody
asked for, and channel overwrites have been allowing and denying the wrong
things.

The three now send the decimal value, and the matching getters read it back the
same way rather than through fromBitSet.

getBitSet itself is untouched: a binary representation is a reasonable thing to
expose, and it has its own test. It just is not what goes on the wire.

The reason this survived is that the test asserted the payload equalled
getBitSet(), so it described the behaviour rather than the requirement and
passed either way. Both tests now assert the literal value Discord expects.

While there, EditPermissionsBuilderTest built its Bitwise with
new Bitwise(1 << 1, 1 << 2, 1 << 3). The constructor takes a single int, so the
second and third arguments were dropped and the test only ever exercised one
flag; it now uses Bitwise::from.
mikield added a commit to Tempcord/framework that referenced this pull request Aug 22, 2026
…sions

Two fixes that come from the same place.

Registration used to POST each command individually. That costs a request per
command, and Discord's create endpoint only ever adds: a command deleted from
the code stayed registered forever and users kept seeing it. Registration now
groups commands into the sets Discord replaces atomically — the global set, and
one set per guild — and sends each as a single PUT. Anything absent from the
set is removed, which is what makes a deleted command actually disappear.

#[Command(permissions: [])] was collected all the way into CommandDefinition
and then never read, so setting it did nothing. It is now typed as Fenrir's
Permission enum rather than a list of strings, and reaches the payload.

Two things are worth explaining rather than leaving to be discovered:

The permission bit field is written onto the payload directly instead of going
through CommandBuilder::setDefaultMemberPermissions, which sends the binary
representation of the bit field where Discord expects the decimal one. Asking
for ADMINISTRATOR that way grants five permissions nobody asked for. Fixed
upstream in dc-Ragnarok/Fenrir#134; the direct write is correct either way, so
it does not need reverting when that lands.

Fenrir has no bulk overwrite method yet and keeps its HTTP client private on
both Discord and Rest, so CommandRegistrar builds one for itself from the token
already in TempcordConfig. Registration runs once, before the gateway opens, so
the two clients never compete for a rate limit bucket. The method is added in
dc-Ragnarok/Fenrir#133 and this can move over to it once released.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant