Bring the library up to the modern Discord API - #133
Open
mikield wants to merge 13 commits into
Open
Conversation
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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 csandutil/verify-namespacing.share 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
GUILD_MESSAGE_POLLS,DIRECT_MESSAGE_POLLSPollBuilderModalBuilder+ 5 modal componentsGateway 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::$valueswas typedComponentSelectOptions[]and mapped throughArrayMapping, but Discord sends plain strings there. So this:produced two empty
ComponentSelectOptionsobjects — the user's selection was discarded entirely. There was no way to read what someone picked from a select menu. Nowstring[].Modal submissions were unreachable. Nothing consumed
MODAL_SUBMIT, andInteractionDatahad nocomponentsfield to read one from. Added, along with aModalSubmitInteractionthat walks the payload collecting anything with acustom_idand avalue— 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_typebecame aMessageComponentType, which resolves the// @todo enumon it and the matching one on the button filter inInteractionHandler.Pins
Discord moved pins from
/channels/{id}/pinsto/channels/{id}/messages/pins— the new ones paginate and report when each message was pinned.discord-php/httpalready marks the old constants deprecated. The three existing methods stay, marked@deprecated, so nothing breaks;getChannelPins,pinChannelMessageandunpinChannelMessagecover 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.
PUTreplaces 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
dividerandspacingrows 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.VOICE_CHANNEL_EFFECT_SEND,VOICE_CHANNEL_STATUS_UPDATE,VOICE_CHANNEL_START_TIME_UPDATE), same reason.Upstream oddity
discord-php/httpdeclaresSKU_SUBSCRIPTIONSandSKU_SUBSCRIPTIONwith a leading slash, unlike every other endpoint constant, andRequestjoins 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_PERMISSIONSconstant points at a path Discord no longer documents, so I left that one alone rather than wrap a dead endpoint.Not included
discord-php/httphas no constant for it.new-member-welcome— in the OpenAPI spec but not in the resource documentation.