From 09d432b1e1b037486b1056c1e8368375bce9dfb3 Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 04:18:25 +0200 Subject: [PATCH 01/13] Complete the application command endpoints 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. --- src/Rest/GlobalCommand.php | 76 +++++++++++++++++ src/Rest/GuildCommand.php | 139 +++++++++++++++++++++++++++++++ tests/Rest/GlobalCommandTest.php | 93 +++++++++++++++++++++ tests/Rest/GuildCommandTest.php | 104 +++++++++++++++++++++++ 4 files changed, 412 insertions(+) diff --git a/src/Rest/GlobalCommand.php b/src/Rest/GlobalCommand.php index 63ea5bd..498b2a7 100644 --- a/src/Rest/GlobalCommand.php +++ b/src/Rest/GlobalCommand.php @@ -49,6 +49,50 @@ public function createApplicationCommand( } + /** + * @see https://discord.com/developers/docs/interactions/application-commands#get-global-application-command + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\ApplicationCommand> + */ + public function getApplicationCommand( + string $applicationId, + string $commandId + ): PromiseInterface { + return $this->mapPromise( + $this->http->get( + Endpoint::bind( + Endpoint::GLOBAL_APPLICATION_COMMAND, + $applicationId, + $commandId, + ), + ), + ApplicationCommand::class + ); + } + + /** + * @see https://discord.com/developers/docs/interactions/application-commands#edit-global-application-command + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\ApplicationCommand> + */ + public function editApplicationCommand( + string $applicationId, + string $commandId, + CommandBuilder $commandBuilder + ): PromiseInterface { + return $this->mapPromise( + $this->http->patch( + Endpoint::bind( + Endpoint::GLOBAL_APPLICATION_COMMAND, + $applicationId, + $commandId, + ), + $commandBuilder->get(), + ), + ApplicationCommand::class + ); + } + /** * @see https://discord.com/developers/docs/interactions/application-commands#delete-global-application-command * @@ -66,4 +110,36 @@ public function deleteApplicationCommand( ), ); } + + /** + * Replaces the application's entire set of global commands in one request. + * + * Commands that are not part of the given set are deleted, which makes this + * the endpoint to reach for when registering the commands an application + * declares rather than creating them one by one. + * + * @see https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-global-application-commands + * + * @param CommandBuilder[] $commandBuilders + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\ApplicationCommand[]> + */ + public function bulkOverwriteApplicationCommands( + string $applicationId, + array $commandBuilders + ): PromiseInterface { + return $this->mapArrayPromise( + $this->http->put( + Endpoint::bind( + Endpoint::GLOBAL_APPLICATION_COMMANDS, + $applicationId + ), + array_map( + static fn (CommandBuilder $commandBuilder) => $commandBuilder->get(), + array_values($commandBuilders) + ), + ), + ApplicationCommand::class + ); + } } diff --git a/src/Rest/GuildCommand.php b/src/Rest/GuildCommand.php index 07dbb78..2e5ce40 100644 --- a/src/Rest/GuildCommand.php +++ b/src/Rest/GuildCommand.php @@ -6,6 +6,8 @@ use Discord\Http\Endpoint; use Ragnarok\Fenrir\Parts\ApplicationCommand; +use Ragnarok\Fenrir\Parts\ApplicationCommandPermissionObject; +use Ragnarok\Fenrir\Parts\ApplicationCommandPermissionStructure; use Ragnarok\Fenrir\Rest\Helpers\Command\CommandBuilder; use React\Promise\PromiseInterface; @@ -55,6 +57,54 @@ public function createApplicationCommand( ); } + /** + * @see https://discord.com/developers/docs/interactions/application-commands#get-guild-application-command + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\ApplicationCommand> + */ + public function getApplicationCommand( + string $applicationId, + string $guildId, + string $commandId + ): PromiseInterface { + return $this->mapPromise( + $this->http->get( + Endpoint::bind( + Endpoint::GUILD_APPLICATION_COMMAND, + $applicationId, + $guildId, + $commandId, + ), + ), + ApplicationCommand::class + ); + } + + /** + * @see https://discord.com/developers/docs/interactions/application-commands#edit-guild-application-command + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\ApplicationCommand> + */ + public function editApplicationCommand( + string $applicationId, + string $guildId, + string $commandId, + CommandBuilder $commandBuilder + ): PromiseInterface { + return $this->mapPromise( + $this->http->patch( + Endpoint::bind( + Endpoint::GUILD_APPLICATION_COMMAND, + $applicationId, + $guildId, + $commandId, + ), + $commandBuilder->get(), + ), + ApplicationCommand::class + ); + } + /** * @see https://discord.com/developers/docs/interactions/application-commands#delete-guild-application-command * @@ -74,4 +124,93 @@ public function deleteApplicationCommand( ), ); } + + /** + * Replaces the application's entire set of commands in the given guild in + * one request. + * + * Commands that are not part of the given set are deleted, which makes this + * the endpoint to reach for when registering the commands an application + * declares rather than creating them one by one. + * + * @see https://discord.com/developers/docs/interactions/application-commands#bulk-overwrite-guild-application-commands + * + * @param CommandBuilder[] $commandBuilders + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\ApplicationCommand[]> + */ + public function bulkOverwriteApplicationCommands( + string $applicationId, + string $guildId, + array $commandBuilders + ): PromiseInterface { + return $this->mapArrayPromise( + $this->http->put( + Endpoint::bind( + Endpoint::GUILD_APPLICATION_COMMANDS, + $applicationId, + $guildId + ), + array_map( + static fn (CommandBuilder $commandBuilder) => $commandBuilder->get(), + array_values($commandBuilders) + ), + ), + ApplicationCommand::class + ); + } + + /** + * @see https://discord.com/developers/docs/interactions/application-commands#get-application-command-permissions + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\ApplicationCommandPermissionObject> + */ + public function getApplicationCommandPermissions( + string $applicationId, + string $guildId, + string $commandId + ): PromiseInterface { + return $this->mapPromise( + $this->http->get( + Endpoint::bind( + Endpoint::GUILD_APPLICATION_COMMAND_PERMISSIONS, + $applicationId, + $guildId, + $commandId, + ), + ), + ApplicationCommandPermissionObject::class + ); + } + + /** + * Discord only accepts this call when authenticated with a bearer token + * carrying the applications.commands.permissions.update scope; a bot token + * is rejected. + * + * @see https://discord.com/developers/docs/interactions/application-commands#edit-application-command-permissions + * + * @param ApplicationCommandPermissionStructure[] $permissions + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\ApplicationCommandPermissionObject> + */ + public function editApplicationCommandPermissions( + string $applicationId, + string $guildId, + string $commandId, + array $permissions + ): PromiseInterface { + return $this->mapPromise( + $this->http->put( + Endpoint::bind( + Endpoint::GUILD_APPLICATION_COMMAND_PERMISSIONS, + $applicationId, + $guildId, + $commandId, + ), + ['permissions' => array_values($permissions)], + ), + ApplicationCommandPermissionObject::class + ); + } } diff --git a/tests/Rest/GlobalCommandTest.php b/tests/Rest/GlobalCommandTest.php index 490f1d6..42f8cb1 100644 --- a/tests/Rest/GlobalCommandTest.php +++ b/tests/Rest/GlobalCommandTest.php @@ -7,15 +7,58 @@ use Ragnarok\Fenrir\Parts\ApplicationCommand; use Ragnarok\Fenrir\Rest\GlobalCommand; use Ragnarok\Fenrir\Rest\Helpers\Command\CommandBuilder; +use React\Promise\Promise; use Tests\Ragnarok\Fenrir\Rest\HttpHelperTestCase; +use function React\Async\await; + class GlobalCommandTest extends HttpHelperTestCase { protected string $httpItemClass = GlobalCommand::class; + /** + * Discord expects a bare list of command objects, so the builders have to be + * unwrapped and the keys discarded on the way out. + */ + public function testBulkOverwriteSendsAListOfCommandPayloads(): void + { + $sent = null; + + $this->http->shouldReceive('put')->andReturnUsing( + static function ($endpoint, $payload) use (&$sent) { + $sent = $payload; + + return new Promise(static function ($resolve) { + $resolve([]); + }); + } + )->once(); + + await($this->httpItem->bulkOverwriteApplicationCommands('::application id::', [ + 'ping' => CommandBuilder::new()->setName('ping'), + 'pong' => CommandBuilder::new()->setName('pong'), + ])); + + $this->assertSame([0, 1], array_keys($sent)); + $this->assertSame('ping', $sent[0]['name']); + $this->assertSame('pong', $sent[1]['name']); + } + public static function httpBindingsProvider(): array { return [ + 'Get commands' => [ + 'method' => 'getCommands', + 'args' => ['::application id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => [(object) []], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommand::class, + 'array' => true, + ], + ], 'Create application command' => [ 'method' => 'createApplicationCommand', 'args' => [ @@ -30,6 +73,56 @@ public static function httpBindingsProvider(): array 'returnType' => ApplicationCommand::class ], ], + 'Get application command' => [ + 'method' => 'getApplicationCommand', + 'args' => ['::application id::', '::command id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommand::class + ], + ], + 'Edit application command' => [ + 'method' => 'editApplicationCommand', + 'args' => [ + '::application id::', + '::command id::', + CommandBuilder::new() + ], + 'mockOptions' => [ + 'method' => 'patch', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommand::class + ], + ], + 'Delete application command' => [ + 'method' => 'deleteApplicationCommand', + 'args' => ['::application id::', '::command id::'], + 'mockOptions' => [ + 'method' => 'delete', + 'return' => null, + ], + 'validationOptions' => [], + ], + 'Bulk overwrite application commands' => [ + 'method' => 'bulkOverwriteApplicationCommands', + 'args' => [ + '::application id::', + [CommandBuilder::new(), CommandBuilder::new()] + ], + 'mockOptions' => [ + 'method' => 'put', + 'return' => [(object) [], (object) []], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommand::class, + 'array' => true, + ], + ], ]; } } diff --git a/tests/Rest/GuildCommandTest.php b/tests/Rest/GuildCommandTest.php index 66cf182..3206446 100644 --- a/tests/Rest/GuildCommandTest.php +++ b/tests/Rest/GuildCommandTest.php @@ -4,7 +4,10 @@ namespace Tests\Ragnarok\Fenrir\Rest; +use Ragnarok\Fenrir\Enums\ApplicationCommandPermissionType; use Ragnarok\Fenrir\Parts\ApplicationCommand; +use Ragnarok\Fenrir\Parts\ApplicationCommandPermissionObject; +use Ragnarok\Fenrir\Parts\ApplicationCommandPermissionStructure; use Ragnarok\Fenrir\Rest\GuildCommand; use Ragnarok\Fenrir\Rest\Helpers\Command\CommandBuilder; use Tests\Ragnarok\Fenrir\Rest\HttpHelperTestCase; @@ -13,9 +16,31 @@ class GuildCommandTest extends HttpHelperTestCase { protected string $httpItemClass = GuildCommand::class; + private static function permission(): ApplicationCommandPermissionStructure + { + $permission = new ApplicationCommandPermissionStructure(); + $permission->id = '::role id::'; + $permission->type = ApplicationCommandPermissionType::ROLE; + $permission->permission = true; + + return $permission; + } + public static function httpBindingsProvider(): array { return [ + 'Get commands' => [ + 'method' => 'getCommands', + 'args' => ['::guild id::', '::application id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => [(object) []], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommand::class, + 'array' => true, + ], + ], 'Create application command' => [ 'method' => 'createApplicationCommand', 'args' => [ @@ -31,6 +56,85 @@ public static function httpBindingsProvider(): array 'returnType' => ApplicationCommand::class ], ], + 'Get application command' => [ + 'method' => 'getApplicationCommand', + 'args' => ['::application id::', '::guild id::', '::command id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommand::class + ], + ], + 'Edit application command' => [ + 'method' => 'editApplicationCommand', + 'args' => [ + '::application id::', + '::guild id::', + '::command id::', + CommandBuilder::new() + ], + 'mockOptions' => [ + 'method' => 'patch', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommand::class + ], + ], + 'Delete application command' => [ + 'method' => 'deleteApplicationCommand', + 'args' => ['::application id::', '::guild id::', '::command id::'], + 'mockOptions' => [ + 'method' => 'delete', + 'return' => null, + ], + 'validationOptions' => [], + ], + 'Bulk overwrite application commands' => [ + 'method' => 'bulkOverwriteApplicationCommands', + 'args' => [ + '::application id::', + '::guild id::', + [CommandBuilder::new(), CommandBuilder::new()] + ], + 'mockOptions' => [ + 'method' => 'put', + 'return' => [(object) [], (object) []], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommand::class, + 'array' => true, + ], + ], + 'Get application command permissions' => [ + 'method' => 'getApplicationCommandPermissions', + 'args' => ['::application id::', '::guild id::', '::command id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommandPermissionObject::class + ], + ], + 'Edit application command permissions' => [ + 'method' => 'editApplicationCommandPermissions', + 'args' => [ + '::application id::', + '::guild id::', + '::command id::', + [self::permission()] + ], + 'mockOptions' => [ + 'method' => 'put', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => ApplicationCommandPermissionObject::class + ], + ], ]; } } From 2c0ac63e380deea552d7f7a5a41b12627b51ed25 Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 04:20:29 +0200 Subject: [PATCH 02/13] Add the poll gateway intents 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. --- src/Enums/Intent.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Enums/Intent.php b/src/Enums/Intent.php index 6a805d7..afa1509 100644 --- a/src/Enums/Intent.php +++ b/src/Enums/Intent.php @@ -28,4 +28,6 @@ enum Intent: int case GUILD_SCHEDULED_EVENTS = 1 << 16; case AUTO_MODERATION_CONFIGURATION = 1 << 20; case AUTO_MODERATION_EXECUTION = 1 << 21; + case GUILD_MESSAGE_POLLS = 1 << 24; + case DIRECT_MESSAGE_POLLS = 1 << 25; } From cb1f927ac83186c3910cee87c365d1cd8a965a49 Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 04:25:09 +0200 Subject: [PATCH 03/13] Add soundboard support 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. --- src/Constants/Events.php | 13 ++ src/Enums/SoundData.php | 11 ++ .../Events/GuildSoundboardSoundCreate.php | 17 ++ .../Events/GuildSoundboardSoundDelete.php | 18 ++ .../Events/GuildSoundboardSoundUpdate.php | 17 ++ .../Events/GuildSoundboardSoundsUpdate.php | 24 +++ src/Gateway/Events/SoundboardSounds.php | 24 +++ src/Parts/GuildSoundboardSounds.php | 22 +++ src/Parts/SoundboardSound.php | 20 +++ src/Rest/Helpers/GetBase64Sound.php | 15 ++ .../CreateSoundboardSoundBuilder.php | 82 +++++++++ .../ModifySoundboardSoundBuilder.php | 67 +++++++ src/Rest/Rest.php | 2 + src/Rest/Soundboard.php | 170 ++++++++++++++++++ .../CreateSoundboardSoundBuilderTest.php | 49 +++++ .../ModifySoundboardSoundBuilderTest.php | 43 +++++ tests/Rest/SoundboardTest.php | 97 ++++++++++ 17 files changed, 691 insertions(+) create mode 100644 src/Enums/SoundData.php create mode 100644 src/Gateway/Events/GuildSoundboardSoundCreate.php create mode 100644 src/Gateway/Events/GuildSoundboardSoundDelete.php create mode 100644 src/Gateway/Events/GuildSoundboardSoundUpdate.php create mode 100644 src/Gateway/Events/GuildSoundboardSoundsUpdate.php create mode 100644 src/Gateway/Events/SoundboardSounds.php create mode 100644 src/Parts/GuildSoundboardSounds.php create mode 100644 src/Parts/SoundboardSound.php create mode 100644 src/Rest/Helpers/GetBase64Sound.php create mode 100644 src/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilder.php create mode 100644 src/Rest/Helpers/Soundboard/ModifySoundboardSoundBuilder.php create mode 100644 src/Rest/Soundboard.php create mode 100644 tests/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilderTest.php create mode 100644 tests/Rest/Helpers/Soundboard/ModifySoundboardSoundBuilderTest.php create mode 100644 tests/Rest/SoundboardTest.php diff --git a/src/Constants/Events.php b/src/Constants/Events.php index 1dc57f5..946a6d2 100644 --- a/src/Constants/Events.php +++ b/src/Constants/Events.php @@ -54,6 +54,12 @@ class Events final public const GUILD_SCHEDULED_EVENT_USER_ADD = 'GUILD_SCHEDULED_EVENT_USER_ADD'; final public const GUILD_SCHEDULED_EVENT_USER_REMOVE = 'GUILD_SCHEDULED_EVENT_USER_REMOVE'; + final public const GUILD_SOUNDBOARD_SOUND_CREATE = 'GUILD_SOUNDBOARD_SOUND_CREATE'; + final public const GUILD_SOUNDBOARD_SOUND_UPDATE = 'GUILD_SOUNDBOARD_SOUND_UPDATE'; + final public const GUILD_SOUNDBOARD_SOUND_DELETE = 'GUILD_SOUNDBOARD_SOUND_DELETE'; + final public const GUILD_SOUNDBOARD_SOUNDS_UPDATE = 'GUILD_SOUNDBOARD_SOUNDS_UPDATE'; + final public const SOUNDBOARD_SOUNDS = 'SOUNDBOARD_SOUNDS'; + final public const INTEGRATION_CREATE = 'INTEGRATION_CREATE'; final public const INTEGRATION_UPDATE = 'INTEGRATION_UPDATE'; final public const INTEGRATION_DELETE = 'INTEGRATION_DELETE'; @@ -137,6 +143,13 @@ class Events self::GUILD_SCHEDULED_EVENT_USER_REMOVE => \Ragnarok\Fenrir\Gateway\Events\GuildScheduledEventUserRemove::class, + self::GUILD_SOUNDBOARD_SOUND_CREATE => \Ragnarok\Fenrir\Gateway\Events\GuildSoundboardSoundCreate::class, + self::GUILD_SOUNDBOARD_SOUND_UPDATE => \Ragnarok\Fenrir\Gateway\Events\GuildSoundboardSoundUpdate::class, + self::GUILD_SOUNDBOARD_SOUND_DELETE => \Ragnarok\Fenrir\Gateway\Events\GuildSoundboardSoundDelete::class, + self::GUILD_SOUNDBOARD_SOUNDS_UPDATE => + \Ragnarok\Fenrir\Gateway\Events\GuildSoundboardSoundsUpdate::class, + self::SOUNDBOARD_SOUNDS => \Ragnarok\Fenrir\Gateway\Events\SoundboardSounds::class, + self::INTEGRATION_CREATE => \Ragnarok\Fenrir\Gateway\Events\IntegrationCreate::class, self::INTEGRATION_UPDATE => \Ragnarok\Fenrir\Gateway\Events\IntegrationUpdate::class, self::INTEGRATION_DELETE => \Ragnarok\Fenrir\Gateway\Events\IntegrationDelete::class, diff --git a/src/Enums/SoundData.php b/src/Enums/SoundData.php new file mode 100644 index 0000000..6594bef --- /dev/null +++ b/src/Enums/SoundData.php @@ -0,0 +1,11 @@ +value . ';base64,' . base64_encode($content); + } +} diff --git a/src/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilder.php b/src/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilder.php new file mode 100644 index 0000000..0277be5 --- /dev/null +++ b/src/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilder.php @@ -0,0 +1,82 @@ +data['name'] = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->data['name'] ?? null; + } + + public function setSound(string $content, SoundData $soundData): self + { + $this->data['sound'] = $this->getBase64Sound($content, $soundData); + + return $this; + } + + public function getSound(): ?string + { + return $this->data['sound'] ?? null; + } + + public function setVolume(float $volume): self + { + $this->data['volume'] = $volume; + + return $this; + } + + public function getVolume(): ?float + { + return $this->data['volume'] ?? null; + } + + public function setEmojiId(?string $emojiId): self + { + $this->data['emoji_id'] = $emojiId; + + return $this; + } + + public function getEmojiId(): ?string + { + return $this->data['emoji_id'] ?? null; + } + + public function setEmojiName(?string $emojiName): self + { + $this->data['emoji_name'] = $emojiName; + + return $this; + } + + public function getEmojiName(): ?string + { + return $this->data['emoji_name'] ?? null; + } + + public function get(): array + { + return $this->data; + } +} diff --git a/src/Rest/Helpers/Soundboard/ModifySoundboardSoundBuilder.php b/src/Rest/Helpers/Soundboard/ModifySoundboardSoundBuilder.php new file mode 100644 index 0000000..8b9cb92 --- /dev/null +++ b/src/Rest/Helpers/Soundboard/ModifySoundboardSoundBuilder.php @@ -0,0 +1,67 @@ +data['name'] = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->data['name'] ?? null; + } + + public function setVolume(?float $volume): self + { + $this->data['volume'] = $volume; + + return $this; + } + + public function getVolume(): ?float + { + return $this->data['volume'] ?? null; + } + + public function setEmojiId(?string $emojiId): self + { + $this->data['emoji_id'] = $emojiId; + + return $this; + } + + public function getEmojiId(): ?string + { + return $this->data['emoji_id'] ?? null; + } + + public function setEmojiName(?string $emojiName): self + { + $this->data['emoji_name'] = $emojiName; + + return $this; + } + + public function getEmojiName(): ?string + { + return $this->data['emoji_name'] ?? null; + } + + public function get(): array + { + return $this->data; + } +} diff --git a/src/Rest/Rest.php b/src/Rest/Rest.php index 71c7537..cc6cd38 100644 --- a/src/Rest/Rest.php +++ b/src/Rest/Rest.php @@ -21,6 +21,7 @@ class Rest public readonly GuildTemplate $guildTemplate; public readonly Invite $invite; public readonly StageInstance $stageInstance; + public readonly Soundboard $soundboard; public readonly Sticker $sticker; public readonly User $user; public readonly GuildCommand $guildCommand; @@ -44,6 +45,7 @@ public function __construct(private Http $http, private DataMapper $dataMapper, $this->guildTemplate = new GuildTemplate(...$args); $this->invite = new Invite(...$args); $this->stageInstance = new StageInstance(...$args); + $this->soundboard = new Soundboard(...$args); $this->sticker = new Sticker(...$args); $this->user = new User(...$args); $this->guildCommand = new GuildCommand(...$args); diff --git a/src/Rest/Soundboard.php b/src/Rest/Soundboard.php new file mode 100644 index 0000000..d89f47c --- /dev/null +++ b/src/Rest/Soundboard.php @@ -0,0 +1,170 @@ + + */ + public function sendSoundboardSound( + string $channelId, + string $soundId, + ?string $sourceGuildId = null + ): PromiseInterface { + $params = ['sound_id' => $soundId]; + + if (!is_null($sourceGuildId)) { + $params['source_guild_id'] = $sourceGuildId; + } + + return $this->http->post( + Endpoint::bind( + Endpoint::CHANNEL_SEND_SOUNDBOARD_SOUND, + $channelId + ), + $params + ); + } + + /** + * @see https://discord.com/developers/docs/resources/soundboard#list-default-soundboard-sounds + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\SoundboardSound[]> + */ + public function listDefaultSounds(): PromiseInterface + { + return $this->mapArrayPromise( + $this->http->get( + Endpoint::bind(Endpoint::SOUNDBOARD_DEFAULT_SOUNDS) + ), + SoundboardSound::class + ); + } + + /** + * Unlike the default sounds, Discord wraps a guild's sounds in an object + * with an "items" array rather than returning them bare. + * + * @see https://discord.com/developers/docs/resources/soundboard#list-guild-soundboard-sounds + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\GuildSoundboardSounds> + */ + public function listGuildSounds(string $guildId): PromiseInterface + { + return $this->mapPromise( + $this->http->get( + Endpoint::bind( + Endpoint::GUILD_SOUNDBOARD_SOUNDS, + $guildId + ) + ), + GuildSoundboardSounds::class + ); + } + + /** + * @see https://discord.com/developers/docs/resources/soundboard#get-guild-soundboard-sound + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\SoundboardSound> + */ + public function getGuildSound(string $guildId, string $soundId): PromiseInterface + { + return $this->mapPromise( + $this->http->get( + Endpoint::bind( + Endpoint::GUILD_SOUNDBOARD_SOUND, + $guildId, + $soundId + ) + ), + SoundboardSound::class + ); + } + + /** + * @see https://discord.com/developers/docs/resources/soundboard#create-guild-soundboard-sound + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\SoundboardSound> + */ + public function createGuildSound( + string $guildId, + CreateSoundboardSoundBuilder $soundBuilder, + ?string $reason = null + ): PromiseInterface { + return $this->mapPromise( + $this->http->post( + Endpoint::bind( + Endpoint::GUILD_SOUNDBOARD_SOUNDS, + $guildId + ), + $soundBuilder->get(), + $this->getAuditLogReasonHeader($reason) + ), + SoundboardSound::class + ); + } + + /** + * @see https://discord.com/developers/docs/resources/soundboard#modify-guild-soundboard-sound + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\SoundboardSound> + */ + public function modifyGuildSound( + string $guildId, + string $soundId, + ModifySoundboardSoundBuilder $soundBuilder, + ?string $reason = null + ): PromiseInterface { + return $this->mapPromise( + $this->http->patch( + Endpoint::bind( + Endpoint::GUILD_SOUNDBOARD_SOUND, + $guildId, + $soundId + ), + $soundBuilder->get(), + $this->getAuditLogReasonHeader($reason) + ), + SoundboardSound::class + ); + } + + /** + * @see https://discord.com/developers/docs/resources/soundboard#delete-guild-soundboard-sound + * + * @return PromiseInterface + */ + public function deleteGuildSound( + string $guildId, + string $soundId, + ?string $reason = null + ): PromiseInterface { + return $this->http->delete( + Endpoint::bind( + Endpoint::GUILD_SOUNDBOARD_SOUND, + $guildId, + $soundId + ), + null, + $this->getAuditLogReasonHeader($reason) + ); + } +} diff --git a/tests/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilderTest.php b/tests/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilderTest.php new file mode 100644 index 0000000..2d082a0 --- /dev/null +++ b/tests/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilderTest.php @@ -0,0 +1,49 @@ +setName('airhorn') + ->setSound('::audio::', SoundData::MP3) + ->setVolume(0.5) + ->setEmojiName('::emoji::'); + + $this->assertEquals([ + 'name' => 'airhorn', + 'sound' => 'data:audio/mpeg;base64,' . base64_encode('::audio::'), + 'volume' => 0.5, + 'emoji_name' => '::emoji::', + ], $builder->get()); + } + + public function testGettersReturnNullWhenUnset(): void + { + $builder = CreateSoundboardSoundBuilder::new(); + + $this->assertNull($builder->getName()); + $this->assertNull($builder->getSound()); + $this->assertNull($builder->getVolume()); + $this->assertNull($builder->getEmojiId()); + $this->assertNull($builder->getEmojiName()); + } + + public function testItEncodesOggSounds(): void + { + $builder = CreateSoundboardSoundBuilder::new()->setSound('::audio::', SoundData::OGG); + + $this->assertEquals( + 'data:audio/ogg;base64,' . base64_encode('::audio::'), + $builder->getSound() + ); + } +} diff --git a/tests/Rest/Helpers/Soundboard/ModifySoundboardSoundBuilderTest.php b/tests/Rest/Helpers/Soundboard/ModifySoundboardSoundBuilderTest.php new file mode 100644 index 0000000..5bdb3c2 --- /dev/null +++ b/tests/Rest/Helpers/Soundboard/ModifySoundboardSoundBuilderTest.php @@ -0,0 +1,43 @@ +setName('airhorn') + ->setVolume(1.0) + ->setEmojiId('::emoji id::'); + + $this->assertEquals([ + 'name' => 'airhorn', + 'volume' => 1.0, + 'emoji_id' => '::emoji id::', + ], $builder->get()); + } + + /** + * Discord treats these fields as nullable, so clearing one has to survive + * into the payload rather than being dropped as "unset". + */ + public function testItKeepsExplicitNulls(): void + { + $builder = ModifySoundboardSoundBuilder::new() + ->setEmojiId(null) + ->setEmojiName(null) + ->setVolume(null); + + $this->assertEquals([ + 'emoji_id' => null, + 'emoji_name' => null, + 'volume' => null, + ], $builder->get()); + } +} diff --git a/tests/Rest/SoundboardTest.php b/tests/Rest/SoundboardTest.php new file mode 100644 index 0000000..163435d --- /dev/null +++ b/tests/Rest/SoundboardTest.php @@ -0,0 +1,97 @@ + [ + 'method' => 'sendSoundboardSound', + 'args' => ['::channel id::', '::sound id::'], + 'mockOptions' => [ + 'method' => 'post', + 'return' => null, + ], + 'validationOptions' => [], + ], + 'List default sounds' => [ + 'method' => 'listDefaultSounds', + 'args' => [], + 'mockOptions' => [ + 'method' => 'get', + 'return' => [(object) []], + ], + 'validationOptions' => [ + 'returnType' => SoundboardSound::class, + 'array' => true, + ], + ], + 'List guild sounds' => [ + 'method' => 'listGuildSounds', + 'args' => ['::guild id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) ['items' => []], + ], + 'validationOptions' => [ + 'returnType' => GuildSoundboardSounds::class, + ], + ], + 'Get guild sound' => [ + 'method' => 'getGuildSound', + 'args' => ['::guild id::', '::sound id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => SoundboardSound::class, + ], + ], + 'Create guild sound' => [ + 'method' => 'createGuildSound', + 'args' => ['::guild id::', CreateSoundboardSoundBuilder::new()], + 'mockOptions' => [ + 'method' => 'post', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => SoundboardSound::class, + ], + ], + 'Modify guild sound' => [ + 'method' => 'modifyGuildSound', + 'args' => ['::guild id::', '::sound id::', ModifySoundboardSoundBuilder::new()], + 'mockOptions' => [ + 'method' => 'patch', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => SoundboardSound::class, + ], + ], + 'Delete guild sound' => [ + 'method' => 'deleteGuildSound', + 'args' => ['::guild id::', '::sound id::'], + 'mockOptions' => [ + 'method' => 'delete', + 'return' => null, + ], + 'validationOptions' => [], + ], + ]; + } +} From 46589be7096538a0e5079a386e46613cc3eed4e8 Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 04:27:43 +0200 Subject: [PATCH 04/13] Add poll endpoints and let messages carry a poll 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. --- .../PollBuilder/TooManyAnswersException.php | 11 ++ src/Parts/Poll.php | 3 +- src/Parts/PollAnswerCount.php | 15 +++ src/Parts/PollAnswerVoters.php | 19 +++ src/Parts/PollResults.php | 21 ++++ src/Rest/Helpers/Channel/Message/SetPoll.php | 22 ++++ src/Rest/Helpers/Channel/MessageBuilder.php | 2 + src/Rest/Helpers/Channel/PollBuilder.php | 112 ++++++++++++++++++ src/Rest/Poll.php | 71 +++++++++++ src/Rest/Rest.php | 2 + .../Helpers/Channel/MessageBuilderTest.php | 20 ++++ .../Rest/Helpers/Channel/PollBuilderTest.php | 69 +++++++++++ tests/Rest/PollTest.php | 54 +++++++++ 13 files changed, 420 insertions(+), 1 deletion(-) create mode 100644 src/Exceptions/Rest/Helpers/PollBuilder/TooManyAnswersException.php create mode 100644 src/Parts/PollAnswerCount.php create mode 100644 src/Parts/PollAnswerVoters.php create mode 100644 src/Parts/PollResults.php create mode 100644 src/Rest/Helpers/Channel/Message/SetPoll.php create mode 100644 src/Rest/Helpers/Channel/PollBuilder.php create mode 100644 src/Rest/Poll.php create mode 100644 tests/Rest/Helpers/Channel/PollBuilderTest.php create mode 100644 tests/Rest/PollTest.php diff --git a/src/Exceptions/Rest/Helpers/PollBuilder/TooManyAnswersException.php b/src/Exceptions/Rest/Helpers/PollBuilder/TooManyAnswersException.php new file mode 100644 index 0000000..f468009 --- /dev/null +++ b/src/Exceptions/Rest/Helpers/PollBuilder/TooManyAnswersException.php @@ -0,0 +1,11 @@ +data['poll'] = $pollBuilder->get(); + + return $this; + } + + public function getPoll(): ?array + { + return $this->data['poll'] ?? null; + } +} diff --git a/src/Rest/Helpers/Channel/MessageBuilder.php b/src/Rest/Helpers/Channel/MessageBuilder.php index f5692bc..0f453ef 100644 --- a/src/Rest/Helpers/Channel/MessageBuilder.php +++ b/src/Rest/Helpers/Channel/MessageBuilder.php @@ -14,6 +14,7 @@ use Ragnarok\Fenrir\Rest\Helpers\Channel\Message\MultipartMessage; use Ragnarok\Fenrir\Rest\Helpers\Channel\Message\SetContent; use Ragnarok\Fenrir\Rest\Helpers\Channel\Message\SetFlags; +use Ragnarok\Fenrir\Rest\Helpers\Channel\Message\SetPoll; use Ragnarok\Fenrir\Rest\Helpers\Channel\Message\SetTts; use Ragnarok\Fenrir\Rest\Helpers\GetNew; @@ -31,6 +32,7 @@ class MessageBuilder use AllowMentions; use SetContent; use SetFlags; + use SetPoll; use MultipartMessage; use SetTts; diff --git a/src/Rest/Helpers/Channel/PollBuilder.php b/src/Rest/Helpers/Channel/PollBuilder.php new file mode 100644 index 0000000..e42614b --- /dev/null +++ b/src/Rest/Helpers/Channel/PollBuilder.php @@ -0,0 +1,112 @@ +data['question'] = ['text' => $text]; + + return $this; + } + + public function getQuestion(): ?array + { + return $this->data['question'] ?? null; + } + + /** + * @throws TooManyAnswersException + */ + public function addAnswer(string $text, ?string $emojiName = null, ?string $emojiId = null): self + { + if (count($this->data['answers'] ?? []) >= self::MAX_ANSWERS) { + throw new TooManyAnswersException('A poll can have at most ' . self::MAX_ANSWERS . ' answers'); + } + + $pollMedia = ['text' => $text]; + + if (!is_null($emojiName)) { + $pollMedia['emoji'] = ['name' => $emojiName]; + } + + if (!is_null($emojiId)) { + $pollMedia['emoji'] = ['id' => $emojiId]; + } + + $this->data['answers'][] = ['poll_media' => $pollMedia]; + + return $this; + } + + public function getAnswers(): ?array + { + return $this->data['answers'] ?? null; + } + + /** + * @var int $duration Hours the poll stays open for, up to 32 days + */ + public function setDuration(int $duration): self + { + $this->data['duration'] = $duration; + + return $this; + } + + public function getDuration(): ?int + { + return $this->data['duration'] ?? null; + } + + public function setAllowMultiselect(bool $allowMultiselect): self + { + $this->data['allow_multiselect'] = $allowMultiselect; + + return $this; + } + + public function getAllowMultiselect(): ?bool + { + return $this->data['allow_multiselect'] ?? null; + } + + public function setLayoutType(PollLayoutType $layoutType): self + { + $this->data['layout_type'] = $layoutType->value; + + return $this; + } + + public function getLayoutType(): ?PollLayoutType + { + return isset($this->data['layout_type']) + ? PollLayoutType::from($this->data['layout_type']) + : null; + } + + public function get(): array + { + return $this->data; + } +} diff --git a/src/Rest/Poll.php b/src/Rest/Poll.php new file mode 100644 index 0000000..0b268a5 --- /dev/null +++ b/src/Rest/Poll.php @@ -0,0 +1,71 @@ + + */ + public function getAnswerVoters( + string $channelId, + string $messageId, + int $answerId, + ?string $after = null, + ?int $limit = null + ): PromiseInterface { + $endpoint = Endpoint::bind( + Endpoint::MESSAGE_POLL_ANSWER, + $channelId, + $messageId, + $answerId + ); + + if (!is_null($after)) { + $endpoint->addQuery('after', $after); + } + + if (!is_null($limit)) { + $endpoint->addQuery('limit', $limit); + } + + return $this->mapPromise( + $this->http->get($endpoint), + PollAnswerVoters::class + ); + } + + /** + * Ends a poll early. Discord rejects this for polls the current user did + * not create. + * + * @see https://discord.com/developers/docs/resources/poll#end-poll + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\Message> + */ + public function endPoll(string $channelId, string $messageId): PromiseInterface + { + return $this->mapPromise( + $this->http->post( + Endpoint::bind( + Endpoint::MESSAGE_POLL_EXPIRE, + $channelId, + $messageId + ) + ), + Message::class + ); + } +} diff --git a/src/Rest/Rest.php b/src/Rest/Rest.php index cc6cd38..0599a95 100644 --- a/src/Rest/Rest.php +++ b/src/Rest/Rest.php @@ -21,6 +21,7 @@ class Rest public readonly GuildTemplate $guildTemplate; public readonly Invite $invite; public readonly StageInstance $stageInstance; + public readonly Poll $poll; public readonly Soundboard $soundboard; public readonly Sticker $sticker; public readonly User $user; @@ -45,6 +46,7 @@ public function __construct(private Http $http, private DataMapper $dataMapper, $this->guildTemplate = new GuildTemplate(...$args); $this->invite = new Invite(...$args); $this->stageInstance = new StageInstance(...$args); + $this->poll = new Poll(...$args); $this->soundboard = new Soundboard(...$args); $this->sticker = new Sticker(...$args); $this->user = new User(...$args); diff --git a/tests/Rest/Helpers/Channel/MessageBuilderTest.php b/tests/Rest/Helpers/Channel/MessageBuilderTest.php index 873c92c..3b0f60b 100644 --- a/tests/Rest/Helpers/Channel/MessageBuilderTest.php +++ b/tests/Rest/Helpers/Channel/MessageBuilderTest.php @@ -13,6 +13,7 @@ use Ragnarok\Fenrir\Rest\Helpers\Channel\ComponentRowBuilder; use Ragnarok\Fenrir\Rest\Helpers\Channel\EmbedBuilder; use Ragnarok\Fenrir\Rest\Helpers\Channel\MessageBuilder; +use Ragnarok\Fenrir\Rest\Helpers\Channel\PollBuilder; use PHPUnit\Framework\TestCase; class MessageBuilderTest extends TestCase @@ -37,6 +38,25 @@ public function testSetEnforceNonce(): void $this->assertTrue($builder->getEnforceNonce()); } + public function testSetPoll(): void + { + $builder = new MessageBuilder(); + + $this->assertNull($builder->getPoll()); + + $builder->setPoll( + PollBuilder::new() + ->setQuestion('Best language?') + ->addAnswer('PHP') + ); + + $this->assertEquals([ + 'question' => ['text' => 'Best language?'], + 'answers' => [['poll_media' => ['text' => 'PHP']]], + ], $builder->get()['poll']); + $this->assertEquals($builder->get()['poll'], $builder->getPoll()); + } + public function testSetTts(): void { $builder = new MessageBuilder(); diff --git a/tests/Rest/Helpers/Channel/PollBuilderTest.php b/tests/Rest/Helpers/Channel/PollBuilderTest.php new file mode 100644 index 0000000..135c929 --- /dev/null +++ b/tests/Rest/Helpers/Channel/PollBuilderTest.php @@ -0,0 +1,69 @@ +setQuestion('Best language?') + ->addAnswer('PHP') + ->addAnswer('Rust', emojiName: '::emoji::') + ->setDuration(48) + ->setAllowMultiselect(true) + ->setLayoutType(PollLayoutType::DEFAULT); + + $this->assertEquals([ + 'question' => ['text' => 'Best language?'], + 'answers' => [ + ['poll_media' => ['text' => 'PHP']], + ['poll_media' => ['text' => 'Rust', 'emoji' => ['name' => '::emoji::']]], + ], + 'duration' => 48, + 'allow_multiselect' => true, + 'layout_type' => PollLayoutType::DEFAULT->value, + ], $poll->get()); + } + + public function testACustomEmojiIsSentById(): void + { + $poll = PollBuilder::new()->addAnswer('PHP', emojiId: '::emoji id::'); + + $this->assertEquals( + [['poll_media' => ['text' => 'PHP', 'emoji' => ['id' => '::emoji id::']]]], + $poll->getAnswers() + ); + } + + public function testItRejectsMoreThanTenAnswers(): void + { + $poll = PollBuilder::new(); + + for ($i = 0; $i < PollBuilder::MAX_ANSWERS; $i++) { + $poll->addAnswer('answer ' . $i); + } + + $this->expectException(TooManyAnswersException::class); + + $poll->addAnswer('one too many'); + } + + public function testGettersReturnNullWhenUnset(): void + { + $poll = PollBuilder::new(); + + $this->assertNull($poll->getQuestion()); + $this->assertNull($poll->getAnswers()); + $this->assertNull($poll->getDuration()); + $this->assertNull($poll->getAllowMultiselect()); + $this->assertNull($poll->getLayoutType()); + } +} diff --git a/tests/Rest/PollTest.php b/tests/Rest/PollTest.php new file mode 100644 index 0000000..9bb543f --- /dev/null +++ b/tests/Rest/PollTest.php @@ -0,0 +1,54 @@ + [ + 'method' => 'getAnswerVoters', + 'args' => ['::channel id::', '::message id::', 1], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) ['users' => []], + ], + 'validationOptions' => [ + 'returnType' => PollAnswerVoters::class, + ], + ], + 'Get answer voters with pagination' => [ + 'method' => 'getAnswerVoters', + 'args' => ['::channel id::', '::message id::', 1, '::after::', 100], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) ['users' => []], + ], + 'validationOptions' => [ + 'returnType' => PollAnswerVoters::class, + ], + ], + 'End poll' => [ + 'method' => 'endPoll', + 'args' => ['::channel id::', '::message id::'], + 'mockOptions' => [ + 'method' => 'post', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => Message::class, + ], + ], + ]; + } +} From bb121dc8612c8d1e306d6befca858c5ed5a345ae Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 04:29:27 +0200 Subject: [PATCH 05/13] Add entitlements 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. --- src/Constants/Events.php | 8 ++ src/Enums/EntitlementOwnerType.php | 14 ++ src/Enums/EntitlementType.php | 20 +++ src/Gateway/Events/EntitlementCreate.php | 16 +++ src/Gateway/Events/EntitlementDelete.php | 16 +++ src/Gateway/Events/EntitlementUpdate.php | 16 +++ src/Parts/Entitlement.php | 25 ++++ src/Rest/Entitlement.php | 125 ++++++++++++++++++ .../Entitlement/GetEntitlementsBuilder.php | 121 +++++++++++++++++ src/Rest/Rest.php | 2 + tests/Rest/EntitlementTest.php | 98 ++++++++++++++ .../GetEntitlementsBuilderTest.php | 50 +++++++ 12 files changed, 511 insertions(+) create mode 100644 src/Enums/EntitlementOwnerType.php create mode 100644 src/Enums/EntitlementType.php create mode 100644 src/Gateway/Events/EntitlementCreate.php create mode 100644 src/Gateway/Events/EntitlementDelete.php create mode 100644 src/Gateway/Events/EntitlementUpdate.php create mode 100644 src/Parts/Entitlement.php create mode 100644 src/Rest/Entitlement.php create mode 100644 src/Rest/Helpers/Entitlement/GetEntitlementsBuilder.php create mode 100644 tests/Rest/EntitlementTest.php create mode 100644 tests/Rest/Helpers/Entitlement/GetEntitlementsBuilderTest.php diff --git a/src/Constants/Events.php b/src/Constants/Events.php index 946a6d2..bda681e 100644 --- a/src/Constants/Events.php +++ b/src/Constants/Events.php @@ -27,6 +27,10 @@ class Events final public const THREAD_MEMBER_UPDATE = 'THREAD_MEMBER_UPDATE'; final public const THREAD_MEMBERS_UPDATE = 'THREAD_MEMBERS_UPDATE'; + final public const ENTITLEMENT_CREATE = 'ENTITLEMENT_CREATE'; + final public const ENTITLEMENT_UPDATE = 'ENTITLEMENT_UPDATE'; + final public const ENTITLEMENT_DELETE = 'ENTITLEMENT_DELETE'; + final public const GUILD_CREATE = 'GUILD_CREATE'; final public const GUILD_UPDATE = 'GUILD_UPDATE'; final public const GUILD_DELETE = 'GUILD_DELETE'; @@ -115,6 +119,10 @@ class Events self::THREAD_MEMBER_UPDATE => \Ragnarok\Fenrir\Gateway\Events\ThreadMemberUpdate::class, self::THREAD_MEMBERS_UPDATE => \Ragnarok\Fenrir\Gateway\Events\ThreadMembersUpdate::class, + self::ENTITLEMENT_CREATE => \Ragnarok\Fenrir\Gateway\Events\EntitlementCreate::class, + self::ENTITLEMENT_UPDATE => \Ragnarok\Fenrir\Gateway\Events\EntitlementUpdate::class, + self::ENTITLEMENT_DELETE => \Ragnarok\Fenrir\Gateway\Events\EntitlementDelete::class, + self::GUILD_CREATE => \Ragnarok\Fenrir\Gateway\Events\GuildCreate::class, self::GUILD_UPDATE => \Ragnarok\Fenrir\Gateway\Events\GuildUpdate::class, self::GUILD_DELETE => \Ragnarok\Fenrir\Gateway\Events\GuildDelete::class, diff --git a/src/Enums/EntitlementOwnerType.php b/src/Enums/EntitlementOwnerType.php new file mode 100644 index 0000000..ae00daa --- /dev/null +++ b/src/Enums/EntitlementOwnerType.php @@ -0,0 +1,14 @@ + + */ + public function listEntitlements( + string $applicationId, + ?GetEntitlementsBuilder $getEntitlementsBuilder = null + ): PromiseInterface { + $endpoint = Endpoint::bind( + Endpoint::APPLICATION_ENTITLEMENTS, + $applicationId + ); + + foreach ($getEntitlementsBuilder?->get() ?? [] as $key => $value) { + $endpoint->addQuery($key, $value); + } + + return $this->mapArrayPromise( + $this->http->get($endpoint), + EntitlementPart::class + ); + } + + /** + * @see https://discord.com/developers/docs/resources/entitlement#get-entitlement + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\Entitlement> + */ + public function getEntitlement(string $applicationId, string $entitlementId): PromiseInterface + { + return $this->mapPromise( + $this->http->get( + Endpoint::bind( + Endpoint::APPLICATION_ENTITLEMENT, + $applicationId, + $entitlementId + ) + ), + EntitlementPart::class + ); + } + + /** + * Marks a one-time purchase entitlement as used up. Only entitlements for + * consumable SKUs can be consumed. + * + * @see https://discord.com/developers/docs/resources/entitlement#consume-an-entitlement + * + * @return PromiseInterface + */ + public function consumeEntitlement(string $applicationId, string $entitlementId): PromiseInterface + { + return $this->http->post( + Endpoint::bind( + Endpoint::APPLICATION_ENTITLEMENT_CONSUME, + $applicationId, + $entitlementId + ) + ); + } + + /** + * Grants an entitlement without charging, for testing an application's + * premium paths. The returned entitlement has no starts_at or ends_at. + * + * @see https://discord.com/developers/docs/resources/entitlement#create-test-entitlement + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\Entitlement> + */ + public function createTestEntitlement( + string $applicationId, + string $skuId, + string $ownerId, + EntitlementOwnerType $ownerType + ): PromiseInterface { + return $this->mapPromise( + $this->http->post( + Endpoint::bind( + Endpoint::APPLICATION_ENTITLEMENTS, + $applicationId + ), + [ + 'sku_id' => $skuId, + 'owner_id' => $ownerId, + 'owner_type' => $ownerType->value, + ] + ), + EntitlementPart::class + ); + } + + /** + * @see https://discord.com/developers/docs/resources/entitlement#delete-test-entitlement + * + * @return PromiseInterface + */ + public function deleteTestEntitlement(string $applicationId, string $entitlementId): PromiseInterface + { + return $this->http->delete( + Endpoint::bind( + Endpoint::APPLICATION_ENTITLEMENT, + $applicationId, + $entitlementId + ) + ); + } +} diff --git a/src/Rest/Helpers/Entitlement/GetEntitlementsBuilder.php b/src/Rest/Helpers/Entitlement/GetEntitlementsBuilder.php new file mode 100644 index 0000000..579ee8b --- /dev/null +++ b/src/Rest/Helpers/Entitlement/GetEntitlementsBuilder.php @@ -0,0 +1,121 @@ +data['user_id'] = $userId; + + return $this; + } + + public function getUserId(): ?string + { + return $this->data['user_id'] ?? null; + } + + /** + * @param string[] $skuIds Discord expects these comma delimited + */ + public function setSkuIds(array $skuIds): self + { + $this->data['sku_ids'] = implode(',', $skuIds); + + return $this; + } + + public function getSkuIds(): ?string + { + return $this->data['sku_ids'] ?? null; + } + + public function setBefore(string $before): self + { + $this->data['before'] = $before; + + return $this; + } + + public function getBefore(): ?string + { + return $this->data['before'] ?? null; + } + + public function setAfter(string $after): self + { + $this->data['after'] = $after; + + return $this; + } + + public function getAfter(): ?string + { + return $this->data['after'] ?? null; + } + + public function setLimit(int $limit): self + { + $this->data['limit'] = $limit; + + return $this; + } + + public function getLimit(): ?int + { + return $this->data['limit'] ?? null; + } + + public function setGuildId(string $guildId): self + { + $this->data['guild_id'] = $guildId; + + return $this; + } + + public function getGuildId(): ?string + { + return $this->data['guild_id'] ?? null; + } + + public function setExcludeEnded(bool $excludeEnded): self + { + $this->data['exclude_ended'] = $excludeEnded; + + return $this; + } + + public function getExcludeEnded(): ?bool + { + return $this->data['exclude_ended'] ?? null; + } + + public function setExcludeDeleted(bool $excludeDeleted): self + { + $this->data['exclude_deleted'] = $excludeDeleted; + + return $this; + } + + public function getExcludeDeleted(): ?bool + { + return $this->data['exclude_deleted'] ?? null; + } + + public function get(): array + { + return $this->data; + } +} diff --git a/src/Rest/Rest.php b/src/Rest/Rest.php index 0599a95..42da3c5 100644 --- a/src/Rest/Rest.php +++ b/src/Rest/Rest.php @@ -15,6 +15,7 @@ class Rest public readonly AuditLog $auditLog; public readonly Channel $channel; public readonly Emoji $emoji; + public readonly Entitlement $entitlement; public readonly GuildAutoModeration $guildAutoModeration; public readonly GuildScheduledEvent $guildScheduledEvent; public readonly GuildSticker $guildSticker; @@ -40,6 +41,7 @@ public function __construct(private Http $http, private DataMapper $dataMapper, $this->auditLog = new AuditLog(...$args); $this->channel = new Channel(...$args); $this->emoji = new Emoji(...$args); + $this->entitlement = new Entitlement(...$args); $this->guildAutoModeration = new GuildAutoModeration(...$args); $this->guildScheduledEvent = new GuildScheduledEvent(...$args); $this->guildSticker = new GuildSticker(...$args); diff --git a/tests/Rest/EntitlementTest.php b/tests/Rest/EntitlementTest.php new file mode 100644 index 0000000..f28888f --- /dev/null +++ b/tests/Rest/EntitlementTest.php @@ -0,0 +1,98 @@ + [ + 'method' => 'listEntitlements', + 'args' => ['::application id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => [(object) []], + ], + 'validationOptions' => [ + 'returnType' => EntitlementPart::class, + 'array' => true, + ], + ], + 'List entitlements with filters' => [ + 'method' => 'listEntitlements', + 'args' => [ + '::application id::', + GetEntitlementsBuilder::new() + ->setUserId('::user id::') + ->setSkuIds(['::sku a::', '::sku b::']) + ->setExcludeEnded(true) + ->setLimit(50), + ], + 'mockOptions' => [ + 'method' => 'get', + 'return' => [(object) []], + ], + 'validationOptions' => [ + 'returnType' => EntitlementPart::class, + 'array' => true, + ], + ], + 'Get entitlement' => [ + 'method' => 'getEntitlement', + 'args' => ['::application id::', '::entitlement id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => EntitlementPart::class, + ], + ], + 'Consume entitlement' => [ + 'method' => 'consumeEntitlement', + 'args' => ['::application id::', '::entitlement id::'], + 'mockOptions' => [ + 'method' => 'post', + 'return' => null, + ], + 'validationOptions' => [], + ], + 'Create test entitlement' => [ + 'method' => 'createTestEntitlement', + 'args' => [ + '::application id::', + '::sku id::', + '::guild id::', + EntitlementOwnerType::GUILD_SUBSCRIPTION, + ], + 'mockOptions' => [ + 'method' => 'post', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => EntitlementPart::class, + ], + ], + 'Delete test entitlement' => [ + 'method' => 'deleteTestEntitlement', + 'args' => ['::application id::', '::entitlement id::'], + 'mockOptions' => [ + 'method' => 'delete', + 'return' => null, + ], + 'validationOptions' => [], + ], + ]; + } +} diff --git a/tests/Rest/Helpers/Entitlement/GetEntitlementsBuilderTest.php b/tests/Rest/Helpers/Entitlement/GetEntitlementsBuilderTest.php new file mode 100644 index 0000000..c0bef68 --- /dev/null +++ b/tests/Rest/Helpers/Entitlement/GetEntitlementsBuilderTest.php @@ -0,0 +1,50 @@ +setSkuIds(['::a::', '::b::']); + + $this->assertEquals('::a::,::b::', $builder->getSkuIds()); + $this->assertEquals(['sku_ids' => '::a::,::b::'], $builder->get()); + } + + public function testItBuildsEveryFilter(): void + { + $builder = GetEntitlementsBuilder::new() + ->setUserId('::user::') + ->setBefore('::before::') + ->setAfter('::after::') + ->setLimit(10) + ->setGuildId('::guild::') + ->setExcludeEnded(true) + ->setExcludeDeleted(false); + + $this->assertEquals([ + 'user_id' => '::user::', + 'before' => '::before::', + 'after' => '::after::', + 'limit' => 10, + 'guild_id' => '::guild::', + 'exclude_ended' => true, + 'exclude_deleted' => false, + ], $builder->get()); + } + + public function testItStartsEmpty(): void + { + $this->assertEquals([], GetEntitlementsBuilder::new()->get()); + } +} From 9ee8fc22a8ec061e218ab63d5d11573ebefcd28d Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 04:30:03 +0200 Subject: [PATCH 06/13] Add the guild audit log entry create event 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. --- src/Constants/Events.php | 5 +++++ .../Events/GuildAuditLogEntryCreate.php | 18 ++++++++++++++++++ 2 files changed, 23 insertions(+) create mode 100644 src/Gateway/Events/GuildAuditLogEntryCreate.php diff --git a/src/Constants/Events.php b/src/Constants/Events.php index bda681e..4ad4ef3 100644 --- a/src/Constants/Events.php +++ b/src/Constants/Events.php @@ -35,6 +35,8 @@ class Events final public const GUILD_UPDATE = 'GUILD_UPDATE'; final public const GUILD_DELETE = 'GUILD_DELETE'; + final public const GUILD_AUDIT_LOG_ENTRY_CREATE = 'GUILD_AUDIT_LOG_ENTRY_CREATE'; + final public const GUILD_BAN_ADD = 'GUILD_BAN_ADD'; final public const GUILD_BAN_REMOVE = 'GUILD_BAN_REMOVE'; @@ -127,6 +129,9 @@ class Events self::GUILD_UPDATE => \Ragnarok\Fenrir\Gateway\Events\GuildUpdate::class, self::GUILD_DELETE => \Ragnarok\Fenrir\Gateway\Events\GuildDelete::class, + self::GUILD_AUDIT_LOG_ENTRY_CREATE => + \Ragnarok\Fenrir\Gateway\Events\GuildAuditLogEntryCreate::class, + self::GUILD_BAN_ADD => \Ragnarok\Fenrir\Gateway\Events\GuildBanAdd::class, self::GUILD_BAN_REMOVE => \Ragnarok\Fenrir\Gateway\Events\GuildBanRemove::class, diff --git a/src/Gateway/Events/GuildAuditLogEntryCreate.php b/src/Gateway/Events/GuildAuditLogEntryCreate.php new file mode 100644 index 0000000..48d9970 --- /dev/null +++ b/src/Gateway/Events/GuildAuditLogEntryCreate.php @@ -0,0 +1,18 @@ + Date: Sat, 22 Aug 2026 12:32:49 +0200 Subject: [PATCH 07/13] Add SKUs and subscriptions 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. --- src/Constants/Events.php | 8 ++ src/Enums/SkuFlag.php | 15 ++++ src/Enums/SkuType.php | 16 ++++ src/Enums/SubscriptionStatus.php | 15 ++++ src/Gateway/Events/SubscriptionCreate.php | 16 ++++ src/Gateway/Events/SubscriptionDelete.php | 16 ++++ src/Gateway/Events/SubscriptionUpdate.php | 16 ++++ src/Parts/Sku.php | 21 ++++++ src/Parts/Subscription.php | 28 +++++++ .../Subscription/GetSubscriptionsBuilder.php | 73 +++++++++++++++++++ src/Rest/Rest.php | 4 + src/Rest/Sku.php | 37 ++++++++++ src/Rest/Subscription.php | 63 ++++++++++++++++ tests/Rest/SkuTest.php | 32 ++++++++ tests/Rest/SubscriptionTest.php | 72 ++++++++++++++++++ 15 files changed, 432 insertions(+) create mode 100644 src/Enums/SkuFlag.php create mode 100644 src/Enums/SkuType.php create mode 100644 src/Enums/SubscriptionStatus.php create mode 100644 src/Gateway/Events/SubscriptionCreate.php create mode 100644 src/Gateway/Events/SubscriptionDelete.php create mode 100644 src/Gateway/Events/SubscriptionUpdate.php create mode 100644 src/Parts/Sku.php create mode 100644 src/Parts/Subscription.php create mode 100644 src/Rest/Helpers/Subscription/GetSubscriptionsBuilder.php create mode 100644 src/Rest/Sku.php create mode 100644 src/Rest/Subscription.php create mode 100644 tests/Rest/SkuTest.php create mode 100644 tests/Rest/SubscriptionTest.php diff --git a/src/Constants/Events.php b/src/Constants/Events.php index 4ad4ef3..7176ea9 100644 --- a/src/Constants/Events.php +++ b/src/Constants/Events.php @@ -90,6 +90,10 @@ class Events final public const STAGE_INSTANCE_UPDATE = 'STAGE_INSTANCE_UPDATE'; final public const STAGE_INSTANCE_DELETE = 'STAGE_INSTANCE_DELETE'; + final public const SUBSCRIPTION_CREATE = 'SUBSCRIPTION_CREATE'; + final public const SUBSCRIPTION_UPDATE = 'SUBSCRIPTION_UPDATE'; + final public const SUBSCRIPTION_DELETE = 'SUBSCRIPTION_DELETE'; + final public const TYPING_START = 'TYPING_START'; final public const USER_UPDATE = 'USER_UPDATE'; @@ -186,6 +190,10 @@ class Events self::STAGE_INSTANCE_UPDATE => \Ragnarok\Fenrir\Gateway\Events\StageInstanceUpdate::class, self::STAGE_INSTANCE_DELETE => \Ragnarok\Fenrir\Gateway\Events\StageInstanceDelete::class, + self::SUBSCRIPTION_CREATE => \Ragnarok\Fenrir\Gateway\Events\SubscriptionCreate::class, + self::SUBSCRIPTION_UPDATE => \Ragnarok\Fenrir\Gateway\Events\SubscriptionUpdate::class, + self::SUBSCRIPTION_DELETE => \Ragnarok\Fenrir\Gateway\Events\SubscriptionDelete::class, + self::TYPING_START => \Ragnarok\Fenrir\Gateway\Events\TypingStart::class, self::USER_UPDATE => \Ragnarok\Fenrir\Gateway\Events\UserUpdate::class, diff --git a/src/Enums/SkuFlag.php b/src/Enums/SkuFlag.php new file mode 100644 index 0000000..5316d7e --- /dev/null +++ b/src/Enums/SkuFlag.php @@ -0,0 +1,15 @@ +data['before'] = $before; + + return $this; + } + + public function getBefore(): ?string + { + return $this->data['before'] ?? null; + } + + public function setAfter(string $after): self + { + $this->data['after'] = $after; + + return $this; + } + + public function getAfter(): ?string + { + return $this->data['after'] ?? null; + } + + /** + * @var int $limit Between 1 and 100, defaults to 50 + */ + public function setLimit(int $limit): self + { + $this->data['limit'] = $limit; + + return $this; + } + + public function getLimit(): ?int + { + return $this->data['limit'] ?? null; + } + + public function setUserId(string $userId): self + { + $this->data['user_id'] = $userId; + + return $this; + } + + public function getUserId(): ?string + { + return $this->data['user_id'] ?? null; + } + + public function get(): array + { + return $this->data; + } +} diff --git a/src/Rest/Rest.php b/src/Rest/Rest.php index 42da3c5..9569365 100644 --- a/src/Rest/Rest.php +++ b/src/Rest/Rest.php @@ -23,7 +23,9 @@ class Rest public readonly Invite $invite; public readonly StageInstance $stageInstance; public readonly Poll $poll; + public readonly Sku $sku; public readonly Soundboard $soundboard; + public readonly Subscription $subscription; public readonly Sticker $sticker; public readonly User $user; public readonly GuildCommand $guildCommand; @@ -49,7 +51,9 @@ public function __construct(private Http $http, private DataMapper $dataMapper, $this->invite = new Invite(...$args); $this->stageInstance = new StageInstance(...$args); $this->poll = new Poll(...$args); + $this->sku = new Sku(...$args); $this->soundboard = new Soundboard(...$args); + $this->subscription = new Subscription(...$args); $this->sticker = new Sticker(...$args); $this->user = new User(...$args); $this->guildCommand = new GuildCommand(...$args); diff --git a/src/Rest/Sku.php b/src/Rest/Sku.php new file mode 100644 index 0000000..2ddb1ee --- /dev/null +++ b/src/Rest/Sku.php @@ -0,0 +1,37 @@ + + */ + public function listSkus(string $applicationId): PromiseInterface + { + return $this->mapArrayPromise( + $this->http->get( + Endpoint::bind( + Endpoint::APPLICATION_SKUS, + $applicationId + ) + ), + SkuPart::class + ); + } +} diff --git a/src/Rest/Subscription.php b/src/Rest/Subscription.php new file mode 100644 index 0000000..160f991 --- /dev/null +++ b/src/Rest/Subscription.php @@ -0,0 +1,63 @@ + + */ + public function listSkuSubscriptions( + string $skuId, + ?GetSubscriptionsBuilder $getSubscriptionsBuilder = null + ): PromiseInterface { + $endpoint = Endpoint::bind(self::path(Endpoint::SKU_SUBSCRIPTIONS), $skuId); + + foreach ($getSubscriptionsBuilder?->get() ?? [] as $key => $value) { + $endpoint->addQuery($key, $value); + } + + return $this->mapArrayPromise( + $this->http->get($endpoint), + SubscriptionPart::class + ); + } + + /** + * @see https://discord.com/developers/docs/resources/subscription#get-sku-subscription + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\Subscription> + */ + public function getSkuSubscription(string $skuId, string $subscriptionId): PromiseInterface + { + return $this->mapPromise( + $this->http->get( + Endpoint::bind(self::path(Endpoint::SKU_SUBSCRIPTION), $skuId, $subscriptionId) + ), + SubscriptionPart::class + ); + } +} diff --git a/tests/Rest/SkuTest.php b/tests/Rest/SkuTest.php new file mode 100644 index 0000000..91c73eb --- /dev/null +++ b/tests/Rest/SkuTest.php @@ -0,0 +1,32 @@ + [ + 'method' => 'listSkus', + 'args' => ['::application id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => [(object) []], + ], + 'validationOptions' => [ + 'returnType' => SkuPart::class, + 'array' => true, + ], + ], + ]; + } +} diff --git a/tests/Rest/SubscriptionTest.php b/tests/Rest/SubscriptionTest.php new file mode 100644 index 0000000..a8363ba --- /dev/null +++ b/tests/Rest/SubscriptionTest.php @@ -0,0 +1,72 @@ +assertSame( + 'skus/::sku id::/subscriptions', + (string) Endpoint::bind(ltrim(Endpoint::SKU_SUBSCRIPTIONS, '/'), '::sku id::') + ); + } + + public static function httpBindingsProvider(): array + { + return [ + 'List SKU subscriptions' => [ + 'method' => 'listSkuSubscriptions', + 'args' => ['::sku id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => [(object) []], + ], + 'validationOptions' => [ + 'returnType' => SubscriptionPart::class, + 'array' => true, + ], + ], + 'List SKU subscriptions with filters' => [ + 'method' => 'listSkuSubscriptions', + 'args' => [ + '::sku id::', + GetSubscriptionsBuilder::new()->setUserId('::user id::')->setLimit(100), + ], + 'mockOptions' => [ + 'method' => 'get', + 'return' => [(object) []], + ], + 'validationOptions' => [ + 'returnType' => SubscriptionPart::class, + 'array' => true, + ], + ], + 'Get SKU subscription' => [ + 'method' => 'getSkuSubscription', + 'args' => ['::sku id::', '::subscription id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => SubscriptionPart::class, + ], + ], + ]; + } +} From b14dbe760b86b33c8bb6c2222631618947284aa5 Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 12:38:13 +0200 Subject: [PATCH 08/13] Make select menus and modal submissions readable MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Interaction/ComponentInteraction.php | 71 ++++++++++++++ src/Interaction/ModalSubmitInteraction.php | 93 +++++++++++++++++++ src/InteractionHandler.php | 3 +- src/Parts/InteractionData.php | 16 +++- .../Interaction/ComponentInteractionTest.php | 73 +++++++++++++++ .../ModalSubmitInteractionTest.php | 84 +++++++++++++++++ tests/InteractionHandlerTest.php | 5 +- 7 files changed, 339 insertions(+), 6 deletions(-) create mode 100644 src/Interaction/ComponentInteraction.php create mode 100644 src/Interaction/ModalSubmitInteraction.php create mode 100644 tests/Interaction/ComponentInteractionTest.php create mode 100644 tests/Interaction/ModalSubmitInteractionTest.php diff --git a/src/Interaction/ComponentInteraction.php b/src/Interaction/ComponentInteraction.php new file mode 100644 index 0000000..5990413 --- /dev/null +++ b/src/Interaction/ComponentInteraction.php @@ -0,0 +1,71 @@ +interaction->data->custom_id ?? null; + } + + public function getComponentType(): ?MessageComponentType + { + return $this->interaction->data->component_type ?? null; + } + + /** + * Everything the user picked in a select menu. Values are the option values + * for a string select, and ids for the user, role, mentionable and channel + * selects. + * + * Empty for anything that is not a select menu, and for a select menu the + * user cleared. + * + * @return string[] + */ + public function getValues(): array + { + return $this->interaction->data->values ?? []; + } + + /** + * The first selected value, for the common case of a select menu that only + * allows one choice. + */ + public function getValue(): ?string + { + return $this->getValues()[0] ?? null; + } + + public function createInteractionResponse( + InteractionCallbackBuilder $interactionCallbackBuilder + ): PromiseInterface { + return $this->discord->rest->webhook->createInteractionResponse( + $this->interaction->id, + $this->interaction->token, + $interactionCallbackBuilder + ); + } +} diff --git a/src/Interaction/ModalSubmitInteraction.php b/src/Interaction/ModalSubmitInteraction.php new file mode 100644 index 0000000..dbceb9b --- /dev/null +++ b/src/Interaction/ModalSubmitInteraction.php @@ -0,0 +1,93 @@ +interaction->data->custom_id ?? null; + } + + /** + * What the user typed into the field with the given custom id. + */ + public function getValue(string $customId): ?string + { + return $this->getValues()[$customId] ?? null; + } + + public function hasValue(string $customId): bool + { + return array_key_exists($customId, $this->getValues()); + } + + /** + * Every submitted field, keyed by its custom id. + * + * Discord nests these differently depending on how the modal was built — + * inside action rows classically, inside labels for the newer components — + * so the payload is walked rather than assumed to be a fixed depth. + * + * @return array + */ + public function getValues(): array + { + $values = []; + + $this->collect($this->interaction->data->components ?? [], $values); + + return $values; + } + + /** + * @param array $values + */ + private function collect(array $components, array &$values): void + { + foreach ($components as $component) { + $component = (array) $component; + + if (isset($component['custom_id'], $component['value'])) { + $values[$component['custom_id']] = $component['value']; + } + + if (!empty($component['components'])) { + $this->collect((array) $component['components'], $values); + } + + if (!empty($component['component'])) { + $this->collect([$component['component']], $values); + } + } + } + + public function createInteractionResponse( + InteractionCallbackBuilder $interactionCallbackBuilder + ): PromiseInterface { + return $this->discord->rest->webhook->createInteractionResponse( + $this->interaction->id, + $this->interaction->token, + $interactionCallbackBuilder + ); + } +} diff --git a/src/InteractionHandler.php b/src/InteractionHandler.php index 4abecf7..f2b3ec3 100644 --- a/src/InteractionHandler.php +++ b/src/InteractionHandler.php @@ -7,6 +7,7 @@ use Ragnarok\Fenrir\Component\Button\InteractionButton; use Ragnarok\Fenrir\Constants\Events; use Ragnarok\Fenrir\Enums\InteractionType; +use Ragnarok\Fenrir\Enums\MessageComponentType; use Ragnarok\Fenrir\Extension\Extension; use Ragnarok\Fenrir\Gateway\Events\InteractionCreate; use Ragnarok\Fenrir\Gateway\Events\Ready; @@ -65,7 +66,7 @@ public function initialize(Discord $discord): void fn (InteractionCreate $interactionCreate) => isset($interactionCreate) && $interactionCreate?->type === InteractionType::MESSAGE_COMPONENT - && $interactionCreate->data->component_type === 2 // @todo enum + && $interactionCreate->data->component_type === MessageComponentType::BUTTON && isset($this->handlersButton[$interactionCreate->data->custom_id]) ); diff --git a/src/Parts/InteractionData.php b/src/Parts/InteractionData.php index e2a738b..833709f 100644 --- a/src/Parts/InteractionData.php +++ b/src/Parts/InteractionData.php @@ -4,6 +4,7 @@ namespace Ragnarok\Fenrir\Parts; +use Ragnarok\Fenrir\Enums\MessageComponentType; use Ragnarok\Fenrir\Mapping\ArrayMapping; class InteractionData @@ -20,10 +21,19 @@ class InteractionData public ?string $guild_id; public ?string $target_id; public ?string $custom_id; - public ?int $component_type; // @todo enum + public ?MessageComponentType $component_type; /** - * @var ComponentSelectOptions[] + * The values a user picked in a select menu. Discord sends these as plain + * strings: the option values for a string select, and ids for the user, + * role, mentionable and channel selects. + * + * @var ?string[] */ - #[ArrayMapping(ComponentSelectOptions::class)] public ?array $values; + /** + * What a user submitted in a modal. The shape is nested and differs between + * classic action row modals and label based ones, so it is left as the raw + * payload; ModalSubmitInteraction reads it. + */ + public ?array $components; } diff --git a/tests/Interaction/ComponentInteractionTest.php b/tests/Interaction/ComponentInteractionTest.php new file mode 100644 index 0000000..bcc6b12 --- /dev/null +++ b/tests/Interaction/ComponentInteractionTest.php @@ -0,0 +1,73 @@ +map((object) ['data' => (object) $data], InteractionCreate::class), + Mockery::mock(Discord::class) + ); + } + + /** + * Discord sends the picked values as plain strings; they used to be mapped + * into option objects, which silently discarded every selection. + */ + public function testItReadsSelectedValues(): void + { + $interaction = $this->interaction([ + 'custom_id' => 'colours', + 'component_type' => 3, + 'values' => ['red', 'green'], + ]); + + $this->assertEquals(['red', 'green'], $interaction->getValues()); + $this->assertEquals('red', $interaction->getValue()); + $this->assertEquals('colours', $interaction->getCustomId()); + $this->assertEquals(MessageComponentType::STRING_SELECT, $interaction->getComponentType()); + } + + public function testAButtonHasNoValues(): void + { + $interaction = $this->interaction([ + 'custom_id' => 'confirm', + 'component_type' => 2, + ]); + + $this->assertEquals([], $interaction->getValues()); + $this->assertNull($interaction->getValue()); + $this->assertEquals(MessageComponentType::BUTTON, $interaction->getComponentType()); + } + + public function testAUserSelectCarriesIds(): void + { + $interaction = $this->interaction([ + 'custom_id' => 'pick-user', + 'component_type' => 5, + 'values' => ['80351110224678912'], + ]); + + $this->assertEquals('80351110224678912', $interaction->getValue()); + $this->assertEquals(MessageComponentType::USER_SELECT, $interaction->getComponentType()); + } +} diff --git a/tests/Interaction/ModalSubmitInteractionTest.php b/tests/Interaction/ModalSubmitInteractionTest.php new file mode 100644 index 0000000..c735cc9 --- /dev/null +++ b/tests/Interaction/ModalSubmitInteractionTest.php @@ -0,0 +1,84 @@ +map((object) ['data' => (object) $data], InteractionCreate::class), + Mockery::mock(Discord::class) + ); + } + + /** + * The classic shape: text inputs wrapped in action rows. + */ + public function testItReadsFieldsNestedInActionRows(): void + { + $interaction = $this->interaction([ + 'custom_id' => 'feedback', + 'components' => [ + (object) ['type' => 1, 'components' => [ + (object) ['type' => 4, 'custom_id' => 'title', 'value' => 'A bug'], + ]], + (object) ['type' => 1, 'components' => [ + (object) ['type' => 4, 'custom_id' => 'body', 'value' => 'It broke'], + ]], + ], + ]); + + $this->assertEquals('feedback', $interaction->getCustomId()); + $this->assertEquals(['title' => 'A bug', 'body' => 'It broke'], $interaction->getValues()); + $this->assertEquals('A bug', $interaction->getValue('title')); + $this->assertTrue($interaction->hasValue('body')); + } + + /** + * The newer shape nests each input under a label instead, so the payload is + * walked rather than assumed to be a fixed two levels deep. + */ + public function testItReadsFieldsNestedInLabels(): void + { + $interaction = $this->interaction([ + 'custom_id' => 'feedback', + 'components' => [ + (object) ['type' => 18, 'component' => (object) [ + 'type' => 4, 'custom_id' => 'title', 'value' => 'A bug', + ]], + ], + ]); + + $this->assertEquals(['title' => 'A bug'], $interaction->getValues()); + } + + public function testAnUnknownFieldIsNull(): void + { + $interaction = $this->interaction(['custom_id' => 'feedback', 'components' => []]); + + $this->assertNull($interaction->getValue('nope')); + $this->assertFalse($interaction->hasValue('nope')); + $this->assertEquals([], $interaction->getValues()); + } + + public function testAModalWithNoComponentsAtAllIsSafe(): void + { + $this->assertEquals([], $this->interaction(['custom_id' => 'empty'])->getValues()); + } +} diff --git a/tests/InteractionHandlerTest.php b/tests/InteractionHandlerTest.php index 8df8630..b5919ef 100644 --- a/tests/InteractionHandlerTest.php +++ b/tests/InteractionHandlerTest.php @@ -11,6 +11,7 @@ use Ragnarok\Fenrir\Component\Button\DangerButton; use Ragnarok\Fenrir\Constants\Events; use Ragnarok\Fenrir\Enums\InteractionType; +use Ragnarok\Fenrir\Enums\MessageComponentType; use Ragnarok\Fenrir\EventHandler; use Ragnarok\Fenrir\Gateway\Events\InteractionCreate; use Ragnarok\Fenrir\Gateway\Objects\Payload; @@ -255,7 +256,7 @@ static function (ButtonInteraction $buttonInteraction) use (&$hasRun) { 'type' => InteractionType::MESSAGE_COMPONENT->value, 'application_id' => '::application id::', 'data' => (object) [ - 'component_type' => 2, // @todo enum + 'component_type' => MessageComponentType::BUTTON->value, 'custom_id' => '::custom id::', ], ], InteractionCreate::class); @@ -289,7 +290,7 @@ static function (ButtonInteraction $buttonInteraction) use (&$runs) { 'type' => InteractionType::MESSAGE_COMPONENT->value, 'application_id' => '::application id::', 'data' => (object) [ - 'component_type' => 2, // @todo enum + 'component_type' => MessageComponentType::BUTTON->value, 'custom_id' => '::custom id::', ], ], InteractionCreate::class); From 7b4072dd4d7d070db068db76601cde2ddeae392f Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 12:43:07 +0200 Subject: [PATCH 09/13] Add components v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Component/V2/Container.php | 77 +++++++++ src/Component/V2/File.php | 42 +++++ src/Component/V2/MediaGallery.php | 56 +++++++ src/Component/V2/MediaGalleryItem.php | 33 ++++ src/Component/V2/Section.php | 61 +++++++ src/Component/V2/Separator.php | 43 +++++ src/Component/V2/TextDisplay.php | 36 ++++ src/Component/V2/Thumbnail.php | 46 ++++++ src/Component/V2/UnfurledMedia.php | 28 ++++ src/Enums/MessageComponentType.php | 12 ++ src/Enums/MessageFlag.php | 7 + src/Enums/SeparatorSpacingSize.php | 14 ++ .../Component/TooManyItemsException.php | 11 ++ src/Rest/Helpers/Channel/ComponentBuilder.php | 54 +++++- tests/Component/V2/ComponentsV2Test.php | 156 ++++++++++++++++++ .../Channel/ComponentBuilderV2Test.php | 67 ++++++++ 16 files changed, 734 insertions(+), 9 deletions(-) create mode 100644 src/Component/V2/Container.php create mode 100644 src/Component/V2/File.php create mode 100644 src/Component/V2/MediaGallery.php create mode 100644 src/Component/V2/MediaGalleryItem.php create mode 100644 src/Component/V2/Section.php create mode 100644 src/Component/V2/Separator.php create mode 100644 src/Component/V2/TextDisplay.php create mode 100644 src/Component/V2/Thumbnail.php create mode 100644 src/Component/V2/UnfurledMedia.php create mode 100644 src/Enums/SeparatorSpacingSize.php create mode 100644 src/Exceptions/Component/TooManyItemsException.php create mode 100644 tests/Component/V2/ComponentsV2Test.php create mode 100644 tests/Rest/Helpers/Channel/ComponentBuilderV2Test.php diff --git a/src/Component/V2/Container.php b/src/Component/V2/Container.php new file mode 100644 index 0000000..a716dfc --- /dev/null +++ b/src/Component/V2/Container.php @@ -0,0 +1,77 @@ + */ + private array $components = []; + + /** + * @param ?int $accentColor RGB, as Discord sends colours elsewhere + */ + public function __construct( + private readonly ?int $accentColor = null, + private readonly ?bool $spoiler = null, + private readonly ?int $id = null + ) { + } + + /** + * @throws TooManyItemsException + */ + public function add(Component|ComponentRowBuilder $component): self + { + if (count($this->components) === self::MAX_COMPONENTS) { + throw new TooManyItemsException( + 'A container can hold at most ' . self::MAX_COMPONENTS . ' components' + ); + } + + $this->components[] = $component; + + return $this; + } + + public function get(): array + { + $data = [ + 'type' => MessageComponentType::CONTAINER->value, + 'components' => array_map( + static fn (Component|ComponentRowBuilder $component) => $component instanceof ComponentRowBuilder + ? ['type' => MessageComponentType::ACTION_ROW->value, 'components' => $component->get()] + : $component->get(), + $this->components + ), + ]; + + if (!is_null($this->accentColor)) { + $data['accent_color'] = $this->accentColor; + } + + if (!is_null($this->spoiler)) { + $data['spoiler'] = $this->spoiler; + } + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/V2/File.php b/src/Component/V2/File.php new file mode 100644 index 0000000..3bb1659 --- /dev/null +++ b/src/Component/V2/File.php @@ -0,0 +1,42 @@ + MessageComponentType::FILE->value, + 'file' => $this->file->get(), + ]; + + if (!is_null($this->spoiler)) { + $data['spoiler'] = $this->spoiler; + } + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/V2/MediaGallery.php b/src/Component/V2/MediaGallery.php new file mode 100644 index 0000000..b2dcfc9 --- /dev/null +++ b/src/Component/V2/MediaGallery.php @@ -0,0 +1,56 @@ +items) === self::MAX_ITEMS) { + throw new TooManyItemsException( + 'A media gallery can hold at most ' . self::MAX_ITEMS . ' items' + ); + } + + $this->items[] = $item; + + return $this; + } + + public function get(): array + { + $data = [ + 'type' => MessageComponentType::MEDIA_GALLERY->value, + 'items' => array_map(static fn (MediaGalleryItem $item) => $item->get(), $this->items), + ]; + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/V2/MediaGalleryItem.php b/src/Component/V2/MediaGalleryItem.php new file mode 100644 index 0000000..0f6f129 --- /dev/null +++ b/src/Component/V2/MediaGalleryItem.php @@ -0,0 +1,33 @@ + $this->media->get()]; + + if (!is_null($this->description)) { + $data['description'] = $this->description; + } + + if (!is_null($this->spoiler)) { + $data['spoiler'] = $this->spoiler; + } + + return $data; + } +} diff --git a/src/Component/V2/Section.php b/src/Component/V2/Section.php new file mode 100644 index 0000000..41796b4 --- /dev/null +++ b/src/Component/V2/Section.php @@ -0,0 +1,61 @@ +components) === self::MAX_COMPONENTS) { + throw new TooManyItemsException( + 'A section can hold at most ' . self::MAX_COMPONENTS . ' text displays' + ); + } + + $this->components[] = $textDisplay; + + return $this; + } + + public function get(): array + { + $data = [ + 'type' => MessageComponentType::SECTION->value, + 'components' => array_map(static fn (TextDisplay $text) => $text->get(), $this->components), + 'accessory' => $this->accessory->get(), + ]; + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/V2/Separator.php b/src/Component/V2/Separator.php new file mode 100644 index 0000000..4800c4c --- /dev/null +++ b/src/Component/V2/Separator.php @@ -0,0 +1,43 @@ + MessageComponentType::SEPARATOR->value]; + + if (!is_null($this->divider)) { + $data['divider'] = $this->divider; + } + + if (!is_null($this->spacing)) { + $data['spacing'] = $this->spacing->value; + } + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/V2/TextDisplay.php b/src/Component/V2/TextDisplay.php new file mode 100644 index 0000000..1315e85 --- /dev/null +++ b/src/Component/V2/TextDisplay.php @@ -0,0 +1,36 @@ + MessageComponentType::TEXT_DISPLAY->value, + 'content' => $this->content, + ]; + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/V2/Thumbnail.php b/src/Component/V2/Thumbnail.php new file mode 100644 index 0000000..755f928 --- /dev/null +++ b/src/Component/V2/Thumbnail.php @@ -0,0 +1,46 @@ + MessageComponentType::THUMBNAIL->value, + 'media' => $this->media->get(), + ]; + + if (!is_null($this->description)) { + $data['description'] = $this->description; + } + + if (!is_null($this->spoiler)) { + $data['spoiler'] = $this->spoiler; + } + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/V2/UnfurledMedia.php b/src/Component/V2/UnfurledMedia.php new file mode 100644 index 0000000..f669277 --- /dev/null +++ b/src/Component/V2/UnfurledMedia.php @@ -0,0 +1,28 @@ +. + * + * @see https://discord.com/developers/docs/components/reference#unfurled-media-item-structure + */ +class UnfurledMedia +{ + public function __construct(public readonly string $url) + { + } + + public static function attachment(string $filename): self + { + return new self('attachment://' . $filename); + } + + public function get(): array + { + return ['url' => $this->url]; + } +} diff --git a/src/Enums/MessageComponentType.php b/src/Enums/MessageComponentType.php index cbe2ed7..8f71f6d 100644 --- a/src/Enums/MessageComponentType.php +++ b/src/Enums/MessageComponentType.php @@ -14,4 +14,16 @@ enum MessageComponentType: int case ROLE_SELECT = 6; case MENTIONABLE_SELECT = 7; case CHANNEL_SELECT = 8; + case SECTION = 9; + case TEXT_DISPLAY = 10; + case THUMBNAIL = 11; + case MEDIA_GALLERY = 12; + case FILE = 13; + case SEPARATOR = 14; + case CONTAINER = 17; + case LABEL = 18; + case FILE_UPLOAD = 19; + case RADIO_GROUP = 21; + case CHECKBOX_GROUP = 22; + case CHECKBOX = 23; } diff --git a/src/Enums/MessageFlag.php b/src/Enums/MessageFlag.php index 5f30f86..caa8ab0 100644 --- a/src/Enums/MessageFlag.php +++ b/src/Enums/MessageFlag.php @@ -16,4 +16,11 @@ enum MessageFlag: int case LOADING = 1 << 7; case FAILED_TO_MENTION_SOME_ROLES_IN_THREAD = 1 << 8; case SUPPRESS_NOTIFICATIONS = 1 << 12; + case IS_VOICE_MESSAGE = 1 << 13; + case HAS_SNAPSHOT = 1 << 14; + /** + * Opts a message into the components v2 layout. Required to use any + * component above type 8, and mutually exclusive with content and embeds. + */ + case IS_COMPONENTS_V2 = 1 << 15; } diff --git a/src/Enums/SeparatorSpacingSize.php b/src/Enums/SeparatorSpacingSize.php new file mode 100644 index 0000000..2f0b0ab --- /dev/null +++ b/src/Enums/SeparatorSpacingSize.php @@ -0,0 +1,14 @@ + + */ + private array $components = []; public function get(): array { - return array_map(static fn (ComponentRowBuilder $row) => [ - 'type' => 1, - 'components' => $row->get() - ], $this->rows); + return array_map( + static fn (ComponentRowBuilder|Component $component) => $component instanceof ComponentRowBuilder + ? [ + 'type' => MessageComponentType::ACTION_ROW->value, + 'components' => $component->get(), + ] + : $component->get(), + $this->components + ); } /** @@ -32,11 +44,24 @@ public function get(): array */ public function addRow(ComponentRowBuilder $componentRow): self { - if (count($this->rows) === 5) { + if (count($this->getRows()) === 5) { throw new TooManyRowsException(); } - $this->rows[] = $componentRow; + $this->components[] = $componentRow; + + return $this; + } + + /** + * Adds a top level component rather than a row. + * + * Everything above type 8 requires the message to carry the + * IS_COMPONENTS_V2 flag, which also means it can have no content or embeds. + */ + public function add(Component $component): self + { + $this->components[] = $component; return $this; } @@ -46,6 +71,17 @@ public function addRow(ComponentRowBuilder $componentRow): self */ public function getRows(): array { - return $this->rows; + return array_values(array_filter( + $this->components, + static fn (ComponentRowBuilder|Component $component) => $component instanceof ComponentRowBuilder + )); + } + + /** + * @return array + */ + public function getComponents(): array + { + return $this->components; } } diff --git a/tests/Component/V2/ComponentsV2Test.php b/tests/Component/V2/ComponentsV2Test.php new file mode 100644 index 0000000..bd53d16 --- /dev/null +++ b/tests/Component/V2/ComponentsV2Test.php @@ -0,0 +1,156 @@ +assertEquals( + ['type' => 10, 'content' => 'Hello'], + new TextDisplay('Hello')->get() + ); + } + + public function testTextDisplayCarriesAnId(): void + { + $this->assertEquals( + ['type' => 10, 'content' => 'Hello', 'id' => 7], + new TextDisplay('Hello', 7)->get() + ); + } + + /** + * Every optional field is left out entirely rather than sent as null, so + * Discord applies its own defaults. + */ + public function testAnEmptySeparatorSendsOnlyItsType(): void + { + $this->assertEquals(['type' => 14], new Separator()->get()); + } + + public function testSeparatorWithSpacingAndDivider(): void + { + $this->assertEquals( + ['type' => 14, 'divider' => true, 'spacing' => 2], + new Separator(divider: true, spacing: SeparatorSpacingSize::LARGE)->get() + ); + } + + public function testThumbnail(): void + { + $this->assertEquals( + [ + 'type' => 11, + 'media' => ['url' => 'https://example.test/a.png'], + 'description' => 'A picture', + 'spoiler' => true, + ], + new Thumbnail( + new UnfurledMedia('https://example.test/a.png'), + description: 'A picture', + spoiler: true + )->get() + ); + } + + public function testAFileReferencesAnAttachmentOnTheSameMessage(): void + { + $this->assertEquals( + ['type' => 13, 'file' => ['url' => 'attachment://report.pdf']], + new File(UnfurledMedia::attachment('report.pdf'))->get() + ); + } + + public function testMediaGallery(): void + { + $gallery = new MediaGallery() + ->add(new MediaGalleryItem(new UnfurledMedia('https://example.test/a.png'))) + ->add(new MediaGalleryItem(new UnfurledMedia('https://example.test/b.png'), 'Second')); + + $this->assertEquals([ + 'type' => 12, + 'items' => [ + ['media' => ['url' => 'https://example.test/a.png']], + ['media' => ['url' => 'https://example.test/b.png'], 'description' => 'Second'], + ], + ], $gallery->get()); + } + + public function testAGalleryRejectsAnEleventhItem(): void + { + $gallery = new MediaGallery(); + + for ($i = 0; $i < MediaGallery::MAX_ITEMS; $i++) { + $gallery->add(new MediaGalleryItem(new UnfurledMedia('https://example.test/' . $i . '.png'))); + } + + $this->expectException(TooManyItemsException::class); + + $gallery->add(new MediaGalleryItem(new UnfurledMedia('https://example.test/x.png'))); + } + + public function testSectionWithAButtonAccessory(): void + { + $section = new Section(new PrimaryButton('::custom id::', 'Go')) + ->add(new TextDisplay('Line one')); + + $this->assertEquals([ + 'type' => 9, + 'components' => [['type' => 10, 'content' => 'Line one']], + 'accessory' => [ + 'type' => 2, + 'style' => 1, + 'custom_id' => '::custom id::', + 'disabled' => false, + 'label' => 'Go', + ], + ], $section->get()); + } + + public function testASectionRejectsAFourthTextDisplay(): void + { + $section = new Section(new PrimaryButton('::custom id::')); + + for ($i = 0; $i < Section::MAX_COMPONENTS; $i++) { + $section->add(new TextDisplay('line ' . $i)); + } + + $this->expectException(TooManyItemsException::class); + + $section->add(new TextDisplay('one too many')); + } + + public function testContainerNestsItsChildren(): void + { + $container = new Container(accentColor: 0x5865F2, spoiler: true) + ->add(new TextDisplay('Inside')) + ->add(new Separator()); + + $this->assertEquals([ + 'type' => 17, + 'components' => [ + ['type' => 10, 'content' => 'Inside'], + ['type' => 14], + ], + 'accent_color' => 0x5865F2, + 'spoiler' => true, + ], $container->get()); + } +} diff --git a/tests/Rest/Helpers/Channel/ComponentBuilderV2Test.php b/tests/Rest/Helpers/Channel/ComponentBuilderV2Test.php new file mode 100644 index 0000000..eb0bf83 --- /dev/null +++ b/tests/Rest/Helpers/Channel/ComponentBuilderV2Test.php @@ -0,0 +1,67 @@ +add(new TextDisplay('Above')) + ->addRow(ComponentRowBuilder::new()->add(new PrimaryButton('::a::'))) + ->add(new TextDisplay('Below')); + + $built = $components->get(); + + $this->assertEquals(['type' => 10, 'content' => 'Above'], $built[0]); + $this->assertEquals(1, $built[1]['type']); + $this->assertEquals(['type' => 10, 'content' => 'Below'], $built[2]); + } + + public function testGetRowsStillOnlyReturnsRows(): void + { + $components = ComponentBuilder::new() + ->add(new TextDisplay('Above')) + ->addRow(ComponentRowBuilder::new()) + ->addRow(ComponentRowBuilder::new()); + + $this->assertCount(2, $components->getRows()); + $this->assertCount(3, $components->getComponents()); + } + + public function testAContainerReachesTheMessagePayload(): void + { + $message = MessageBuilder::new() + ->setFlags(MessageFlag::IS_COMPONENTS_V2->value) + ->setComponents( + ComponentBuilder::new()->add( + new Container()->add(new TextDisplay('Hello')) + ) + ); + + $payload = $message->get(); + + $this->assertEquals(MessageFlag::IS_COMPONENTS_V2->value, $payload['flags']); + $this->assertEquals([ + [ + 'type' => 17, + 'components' => [['type' => 10, 'content' => 'Hello']], + ], + ], $payload['components']); + } +} From 62def9bea18872da767053e19564b8347c3b836a Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 12:55:34 +0200 Subject: [PATCH 10/13] Add modal building MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/Component/Modal/Checkbox.php | 41 +++++++ src/Component/Modal/CheckboxGroup.php | 74 ++++++++++++ src/Component/Modal/FileUpload.php | 56 +++++++++ src/Component/Modal/Label.php | 44 +++++++ src/Component/Modal/Option.php | 39 +++++++ src/Component/Modal/RadioGroup.php | 65 +++++++++++ .../Helpers/InteractionCallbackBuilder.php | 25 ++++ src/Interaction/Helpers/ModalBuilder.php | 92 +++++++++++++++ tests/Component/Modal/ModalComponentsTest.php | 108 ++++++++++++++++++ .../Interaction/Helpers/ModalBuilderTest.php | 77 +++++++++++++ 10 files changed, 621 insertions(+) create mode 100644 src/Component/Modal/Checkbox.php create mode 100644 src/Component/Modal/CheckboxGroup.php create mode 100644 src/Component/Modal/FileUpload.php create mode 100644 src/Component/Modal/Label.php create mode 100644 src/Component/Modal/Option.php create mode 100644 src/Component/Modal/RadioGroup.php create mode 100644 src/Interaction/Helpers/ModalBuilder.php create mode 100644 tests/Component/Modal/ModalComponentsTest.php create mode 100644 tests/Interaction/Helpers/ModalBuilderTest.php diff --git a/src/Component/Modal/Checkbox.php b/src/Component/Modal/Checkbox.php new file mode 100644 index 0000000..e67c982 --- /dev/null +++ b/src/Component/Modal/Checkbox.php @@ -0,0 +1,41 @@ + MessageComponentType::CHECKBOX->value, + 'custom_id' => $this->customId, + ]; + + if (!is_null($this->default)) { + $data['default'] = $this->default; + } + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/Modal/CheckboxGroup.php b/src/Component/Modal/CheckboxGroup.php new file mode 100644 index 0000000..648150a --- /dev/null +++ b/src/Component/Modal/CheckboxGroup.php @@ -0,0 +1,74 @@ +options) === self::MAX_OPTIONS) { + throw new TooManyItemsException( + 'A checkbox group can hold at most ' . self::MAX_OPTIONS . ' options' + ); + } + + $this->options[] = $option; + + return $this; + } + + public function get(): array + { + $data = [ + 'type' => MessageComponentType::CHECKBOX_GROUP->value, + 'custom_id' => $this->customId, + 'options' => array_map(static fn (Option $option) => $option->get(), $this->options), + ]; + + if (!is_null($this->minValues)) { + $data['min_values'] = $this->minValues; + } + + if (!is_null($this->maxValues)) { + $data['max_values'] = $this->maxValues; + } + + if (!is_null($this->required)) { + $data['required'] = $this->required; + } + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/Modal/FileUpload.php b/src/Component/Modal/FileUpload.php new file mode 100644 index 0000000..4b4ca69 --- /dev/null +++ b/src/Component/Modal/FileUpload.php @@ -0,0 +1,56 @@ + MessageComponentType::FILE_UPLOAD->value, + 'custom_id' => $this->customId, + ]; + + if (!is_null($this->minValues)) { + $data['min_values'] = $this->minValues; + } + + if (!is_null($this->maxValues)) { + $data['max_values'] = $this->maxValues; + } + + if (!is_null($this->required)) { + $data['required'] = $this->required; + } + + if (!is_null($this->fileTypes)) { + $data['file_types'] = array_values($this->fileTypes); + } + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/Modal/Label.php b/src/Component/Modal/Label.php new file mode 100644 index 0000000..ec7f5bc --- /dev/null +++ b/src/Component/Modal/Label.php @@ -0,0 +1,44 @@ + MessageComponentType::LABEL->value, + 'label' => $this->label, + 'component' => $this->component->get(), + ]; + + if (!is_null($this->description)) { + $data['description'] = $this->description; + } + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Component/Modal/Option.php b/src/Component/Modal/Option.php new file mode 100644 index 0000000..b956968 --- /dev/null +++ b/src/Component/Modal/Option.php @@ -0,0 +1,39 @@ + $this->label, + 'value' => $this->value, + ]; + + if (!is_null($this->description)) { + $data['description'] = $this->description; + } + + if (!is_null($this->default)) { + $data['default'] = $this->default; + } + + return $data; + } +} diff --git a/src/Component/Modal/RadioGroup.php b/src/Component/Modal/RadioGroup.php new file mode 100644 index 0000000..2a441e0 --- /dev/null +++ b/src/Component/Modal/RadioGroup.php @@ -0,0 +1,65 @@ +options) === self::MAX_OPTIONS) { + throw new TooManyItemsException( + 'A radio group can hold at most ' . self::MAX_OPTIONS . ' options' + ); + } + + $this->options[] = $option; + + return $this; + } + + public function get(): array + { + $data = [ + 'type' => MessageComponentType::RADIO_GROUP->value, + 'custom_id' => $this->customId, + 'options' => array_map(static fn (Option $option) => $option->get(), $this->options), + ]; + + if (!is_null($this->required)) { + $data['required'] = $this->required; + } + + if (!is_null($this->id)) { + $data['id'] = $this->id; + } + + return $data; + } +} diff --git a/src/Interaction/Helpers/InteractionCallbackBuilder.php b/src/Interaction/Helpers/InteractionCallbackBuilder.php index 2b8ec6a..63e1eb4 100644 --- a/src/Interaction/Helpers/InteractionCallbackBuilder.php +++ b/src/Interaction/Helpers/InteractionCallbackBuilder.php @@ -32,8 +32,26 @@ class InteractionCallbackBuilder private InteractionCallbackType $type; + private ModalBuilder $modal; + private array $data = []; + /** + * Responds to the interaction by opening a modal, which sets the callback + * type as well since a modal cannot be sent as any other kind of response. + */ + public function setModal(ModalBuilder $modal): self + { + $this->modal = $modal; + + return $this->setType(InteractionCallbackType::MODAL); + } + + public function getModal(): ?ModalBuilder + { + return $this->modal ?? null; + } + public function setType(InteractionCallbackType $type): self { $this->type = $type; @@ -50,6 +68,13 @@ public function get(): array|MultipartBody { $callbackData = $this->data; + if (isset($this->modal)) { + return [ + 'type' => $this->type->value, + 'data' => $this->modal->get(), + ]; + } + if ($this->hasComponents()) { $callbackData['components'] = $this->getComponents()->get(); } diff --git a/src/Interaction/Helpers/ModalBuilder.php b/src/Interaction/Helpers/ModalBuilder.php new file mode 100644 index 0000000..a3f4e4f --- /dev/null +++ b/src/Interaction/Helpers/ModalBuilder.php @@ -0,0 +1,92 @@ +customId = $customId; + + return $this; + } + + public function getCustomId(): ?string + { + return $this->customId ?? null; + } + + public function setTitle(string $title): self + { + $this->title = $title; + + return $this; + } + + public function getTitle(): ?string + { + return $this->title ?? null; + } + + /** + * @throws TooManyItemsException + */ + public function add(Component $component): self + { + if (count($this->components) === self::MAX_COMPONENTS) { + throw new TooManyItemsException( + 'A modal can hold at most ' . self::MAX_COMPONENTS . ' components' + ); + } + + $this->components[] = $component; + + return $this; + } + + /** + * @return Component[] + */ + public function getComponents(): array + { + return $this->components; + } + + public function get(): array + { + return [ + 'custom_id' => $this->customId, + 'title' => $this->title, + 'components' => array_map( + static fn (Component $component) => $component->get(), + $this->components + ), + ]; + } +} diff --git a/tests/Component/Modal/ModalComponentsTest.php b/tests/Component/Modal/ModalComponentsTest.php new file mode 100644 index 0000000..b773f6d --- /dev/null +++ b/tests/Component/Modal/ModalComponentsTest.php @@ -0,0 +1,108 @@ +get(); + + $this->assertEquals(18, $built['type']); + $this->assertEquals('Your name', $built['label']); + $this->assertEquals('As it appears on your account', $built['description']); + $this->assertEquals(4, $built['component']['type']); + $this->assertEquals('name', $built['component']['custom_id']); + } + + public function testCheckbox(): void + { + $this->assertEquals( + ['type' => 23, 'custom_id' => 'agree', 'default' => true], + new Checkbox('agree', default: true)->get() + ); + } + + public function testFileUpload(): void + { + $this->assertEquals( + [ + 'type' => 19, + 'custom_id' => 'evidence', + 'max_values' => 3, + 'required' => true, + ], + new FileUpload('evidence', maxValues: 3, required: true)->get() + ); + } + + public function testAnEmptyFileUploadSendsOnlyWhatIsRequired(): void + { + $this->assertEquals( + ['type' => 19, 'custom_id' => 'evidence'], + new FileUpload('evidence')->get() + ); + } + + public function testRadioGroup(): void + { + $group = new RadioGroup('size', required: true) + ->add(new Option('Small', 's')) + ->add(new Option('Large', 'l', description: 'Costs more', default: true)); + + $this->assertEquals([ + 'type' => 21, + 'custom_id' => 'size', + 'options' => [ + ['label' => 'Small', 'value' => 's'], + ['label' => 'Large', 'value' => 'l', 'description' => 'Costs more', 'default' => true], + ], + 'required' => true, + ], $group->get()); + } + + public function testCheckboxGroup(): void + { + $group = new CheckboxGroup('toppings', minValues: 1, maxValues: 2) + ->add(new Option('Cheese', 'cheese')); + + $built = $group->get(); + + $this->assertEquals(22, $built['type']); + $this->assertEquals(1, $built['min_values']); + $this->assertEquals(2, $built['max_values']); + $this->assertCount(1, $built['options']); + } + + public function testAGroupRejectsAnEleventhOption(): void + { + $group = new CheckboxGroup('toppings'); + + for ($i = 0; $i < CheckboxGroup::MAX_OPTIONS; $i++) { + $group->add(new Option('option ' . $i, (string) $i)); + } + + $this->expectException(TooManyItemsException::class); + + $group->add(new Option('one too many', 'x')); + } +} diff --git a/tests/Interaction/Helpers/ModalBuilderTest.php b/tests/Interaction/Helpers/ModalBuilderTest.php new file mode 100644 index 0000000..6c226ee --- /dev/null +++ b/tests/Interaction/Helpers/ModalBuilderTest.php @@ -0,0 +1,77 @@ +setCustomId('feedback') + ->setTitle('Send feedback') + ->add(new Label('Title', new TextInput('title', TextInputStyle::Short, 'Title'))) + ->add(new Label('Details', new TextInput('body', TextInputStyle::Paragraph, 'Details'))); + } + + public function testItBuildsTheModalPayload(): void + { + $built = $this->modal()->get(); + + $this->assertEquals('feedback', $built['custom_id']); + $this->assertEquals('Send feedback', $built['title']); + $this->assertCount(2, $built['components']); + $this->assertEquals(18, $built['components'][0]['type']); + $this->assertEquals('title', $built['components'][0]['component']['custom_id']); + } + + public function testGettersReturnNullWhenUnset(): void + { + $modal = ModalBuilder::new(); + + $this->assertNull($modal->getCustomId()); + $this->assertNull($modal->getTitle()); + $this->assertEquals([], $modal->getComponents()); + } + + /** + * A modal cannot be sent as any other kind of response, so setting one also + * settles the callback type. + */ + public function testSettingAModalSelectsTheCallbackType(): void + { + $callback = InteractionCallbackBuilder::new()->setModal($this->modal()); + + $this->assertEquals(InteractionCallbackType::MODAL, $callback->getType()); + + $built = $callback->get(); + + $this->assertEquals(InteractionCallbackType::MODAL->value, $built['type']); + $this->assertEquals('feedback', $built['data']['custom_id']); + $this->assertCount(2, $built['data']['components']); + } + + /** + * The message oriented fields on the callback builder have no meaning in a + * modal response and must not leak into it. + */ + public function testMessageFieldsDoNotLeakIntoAModalResponse(): void + { + $built = InteractionCallbackBuilder::new() + ->setContent('::ignored::') + ->setModal($this->modal()) + ->get(); + + $this->assertArrayNotHasKey('content', $built['data']); + $this->assertEquals(['custom_id', 'title', 'components'], array_keys($built['data'])); + } +} From 55780c12101f17621e4a295aaf1c1fe4bcaad8e8 Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 13:00:01 +0200 Subject: [PATCH 11/13] Add guild onboarding and the remaining guild endpoints 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. --- src/Enums/GuildOnboardingMode.php | 17 +++ src/Enums/OnboardingPromptType.php | 14 ++ src/Parts/BulkBanResult.php | 16 +++ src/Parts/GuildOnboarding.php | 25 ++++ src/Parts/OnboardingPrompt.php | 26 ++++ src/Parts/OnboardingPromptOption.php | 20 +++ src/Rest/Guild.php | 130 ++++++++++++++++++ .../Guild/ModifyGuildOnboardingBuilder.php | 95 +++++++++++++ .../Guild/ModifyWelcomeScreenBuilder.php | 68 +++++++++ .../Helpers/Guild/OnboardingPromptBuilder.php | 117 ++++++++++++++++ .../Guild/OnboardingPromptOptionBuilder.php | 106 ++++++++++++++ tests/Rest/GuildTest.php | 58 ++++++++ .../Helpers/Guild/OnboardingBuildersTest.php | 110 +++++++++++++++ 13 files changed, 802 insertions(+) create mode 100644 src/Enums/GuildOnboardingMode.php create mode 100644 src/Enums/OnboardingPromptType.php create mode 100644 src/Parts/BulkBanResult.php create mode 100644 src/Parts/GuildOnboarding.php create mode 100644 src/Parts/OnboardingPrompt.php create mode 100644 src/Parts/OnboardingPromptOption.php create mode 100644 src/Rest/Helpers/Guild/ModifyGuildOnboardingBuilder.php create mode 100644 src/Rest/Helpers/Guild/ModifyWelcomeScreenBuilder.php create mode 100644 src/Rest/Helpers/Guild/OnboardingPromptBuilder.php create mode 100644 src/Rest/Helpers/Guild/OnboardingPromptOptionBuilder.php create mode 100644 tests/Rest/Helpers/Guild/OnboardingBuildersTest.php diff --git a/src/Enums/GuildOnboardingMode.php b/src/Enums/GuildOnboardingMode.php new file mode 100644 index 0000000..706f367 --- /dev/null +++ b/src/Enums/GuildOnboardingMode.php @@ -0,0 +1,17 @@ + + */ + public function modifyWelcomeScreen( + string $guildId, + ModifyWelcomeScreenBuilder $welcomeScreenBuilder, + ?string $reason = null + ): PromiseInterface { + return $this->mapPromise( + $this->http->patch( + Endpoint::bind( + Endpoint::GUILD_WELCOME_SCREEN, + $guildId, + ), + $welcomeScreenBuilder->get(), + $this->getAuditLogReasonHeader($reason) + ), + WelcomeScreen::class, + ); + } + + /** + * @see https://discord.com/developers/docs/resources/guild#get-guild-onboarding + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\GuildOnboarding> + */ + public function getOnboarding(string $guildId): PromiseInterface + { + return $this->mapPromise( + $this->http->get( + Endpoint::bind( + Endpoint::GUILD_ONBOARDING, + $guildId, + ), + ), + GuildOnboarding::class, + ); + } + + /** + * Replaces the guild's onboarding flow wholesale; prompts left out of the + * request are removed. + * + * Discord requires an id on every prompt and option, including ones being + * created, and accepts a placeholder for those. + * + * @see https://discord.com/developers/docs/resources/guild#modify-guild-onboarding + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\GuildOnboarding> + */ + public function modifyOnboarding( + string $guildId, + ModifyGuildOnboardingBuilder $onboardingBuilder, + ?string $reason = null + ): PromiseInterface { + return $this->mapPromise( + $this->http->put( + Endpoint::bind( + Endpoint::GUILD_ONBOARDING, + $guildId, + ), + $onboardingBuilder->get(), + $this->getAuditLogReasonHeader($reason) + ), + GuildOnboarding::class, + ); + } + + /** + * Bans up to 200 users in one request. Discord reports which of them it + * could not ban rather than failing the whole call, and only rejects the + * request outright when none of them could be banned. + * + * @see https://discord.com/developers/docs/resources/guild#bulk-guild-ban + * + * @param string[] $userIds + * @param ?int $deleteMessageSeconds How much of their recent message + * history to delete, up to 7 days + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\BulkBanResult> + */ + public function bulkBan( + string $guildId, + array $userIds, + ?int $deleteMessageSeconds = null, + ?string $reason = null + ): PromiseInterface { + $params = ['user_ids' => array_values($userIds)]; + + if (!is_null($deleteMessageSeconds)) { + $params['delete_message_seconds'] = $deleteMessageSeconds; + } + + return $this->mapPromise( + $this->http->post( + Endpoint::bind( + Endpoint::GUILD_BAN_BULK, + $guildId, + ), + $params, + $this->getAuditLogReasonHeader($reason) + ), + BulkBanResult::class, + ); + } + + /** + * How many members hold each role, keyed by role id. + * + * The response is a plain map rather than a documented object, so it is + * returned as given. + * + * @return PromiseInterface> + */ + public function getRoleMemberCounts(string $guildId): PromiseInterface + { + return $this->http->get( + Endpoint::bind( + Endpoint::GUILD_ROLES_MEMBER_COUNTS, + $guildId, + ), + ); + } } diff --git a/src/Rest/Helpers/Guild/ModifyGuildOnboardingBuilder.php b/src/Rest/Helpers/Guild/ModifyGuildOnboardingBuilder.php new file mode 100644 index 0000000..1a65f70 --- /dev/null +++ b/src/Rest/Helpers/Guild/ModifyGuildOnboardingBuilder.php @@ -0,0 +1,95 @@ +prompts) === self::MAX_PROMPTS) { + throw new TooManyItemsException( + 'Onboarding can hold at most ' . self::MAX_PROMPTS . ' prompts' + ); + } + + $this->prompts[] = $prompt; + + return $this; + } + + /** @return OnboardingPromptBuilder[] */ + public function getPrompts(): array + { + return $this->prompts; + } + + public function setEnabled(bool $enabled): self + { + $this->data['enabled'] = $enabled; + + return $this; + } + + public function getEnabled(): ?bool + { + return $this->data['enabled'] ?? null; + } + + /** + * @param string[] $channelIds Channels every member is opted into + */ + public function setDefaultChannelIds(array $channelIds): self + { + $this->data['default_channel_ids'] = array_values($channelIds); + + return $this; + } + + /** @return ?string[] */ + public function getDefaultChannelIds(): ?array + { + return $this->data['default_channel_ids'] ?? null; + } + + public function setMode(GuildOnboardingMode $mode): self + { + $this->data['mode'] = $mode->value; + + return $this; + } + + public function get(): array + { + $data = $this->data; + + if ($this->prompts !== []) { + $data['prompts'] = array_map( + static fn (OnboardingPromptBuilder $prompt) => $prompt->get(), + $this->prompts + ); + } + + return $data; + } +} diff --git a/src/Rest/Helpers/Guild/ModifyWelcomeScreenBuilder.php b/src/Rest/Helpers/Guild/ModifyWelcomeScreenBuilder.php new file mode 100644 index 0000000..88524fe --- /dev/null +++ b/src/Rest/Helpers/Guild/ModifyWelcomeScreenBuilder.php @@ -0,0 +1,68 @@ +data['description'] = $description; + + return $this; + } + + public function getDescription(): ?string + { + return $this->data['description'] ?? null; + } + + public function setEnabled(bool $enabled): self + { + $this->data['enabled'] = $enabled; + + return $this; + } + + public function getEnabled(): ?bool + { + return $this->data['enabled'] ?? null; + } + + public function addChannel( + string $channelId, + string $description, + ?string $emojiId = null, + ?string $emojiName = null + ): self { + $this->data['welcome_channels'][] = [ + 'channel_id' => $channelId, + 'description' => $description, + 'emoji_id' => $emojiId, + 'emoji_name' => $emojiName, + ]; + + return $this; + } + + /** @return ?array[] */ + public function getChannels(): ?array + { + return $this->data['welcome_channels'] ?? null; + } + + public function get(): array + { + return $this->data; + } +} diff --git a/src/Rest/Helpers/Guild/OnboardingPromptBuilder.php b/src/Rest/Helpers/Guild/OnboardingPromptBuilder.php new file mode 100644 index 0000000..01dc8d9 --- /dev/null +++ b/src/Rest/Helpers/Guild/OnboardingPromptBuilder.php @@ -0,0 +1,117 @@ +data['id'] = $id; + + return $this; + } + + public function getId(): ?string + { + return $this->data['id'] ?? null; + } + + public function setTitle(string $title): self + { + $this->data['title'] = $title; + + return $this; + } + + public function getTitle(): ?string + { + return $this->data['title'] ?? null; + } + + public function setType(OnboardingPromptType $type): self + { + $this->data['type'] = $type->value; + + return $this; + } + + public function setSingleSelect(bool $singleSelect): self + { + $this->data['single_select'] = $singleSelect; + + return $this; + } + + public function setRequired(bool $required): self + { + $this->data['required'] = $required; + + return $this; + } + + /** + * Whether the prompt appears during onboarding itself, as opposed to only + * in the server guide afterwards. + */ + public function setInOnboarding(bool $inOnboarding): self + { + $this->data['in_onboarding'] = $inOnboarding; + + return $this; + } + + /** + * @throws TooManyItemsException + */ + public function addOption(OnboardingPromptOptionBuilder $option): self + { + if (count($this->options) === self::MAX_OPTIONS) { + throw new TooManyItemsException( + 'An onboarding prompt can hold at most ' . self::MAX_OPTIONS . ' options' + ); + } + + $this->options[] = $option; + + return $this; + } + + /** @return OnboardingPromptOptionBuilder[] */ + public function getOptions(): array + { + return $this->options; + } + + public function get(): array + { + return [ + ...$this->data, + 'options' => array_map( + static fn (OnboardingPromptOptionBuilder $option) => $option->get(), + $this->options + ), + ]; + } +} diff --git a/src/Rest/Helpers/Guild/OnboardingPromptOptionBuilder.php b/src/Rest/Helpers/Guild/OnboardingPromptOptionBuilder.php new file mode 100644 index 0000000..b47dd53 --- /dev/null +++ b/src/Rest/Helpers/Guild/OnboardingPromptOptionBuilder.php @@ -0,0 +1,106 @@ +data['id'] = $id; + + return $this; + } + + public function getId(): ?string + { + return $this->data['id'] ?? null; + } + + public function setTitle(string $title): self + { + $this->data['title'] = $title; + + return $this; + } + + public function getTitle(): ?string + { + return $this->data['title'] ?? null; + } + + public function setDescription(?string $description): self + { + $this->data['description'] = $description; + + return $this; + } + + public function getDescription(): ?string + { + return $this->data['description'] ?? null; + } + + public function setEmoji(?string $emojiId = null, ?string $emojiName = null, ?bool $animated = null): self + { + $this->data['emoji_id'] = $emojiId; + $this->data['emoji_name'] = $emojiName; + + if (!is_null($animated)) { + $this->data['emoji_animated'] = $animated; + } + + return $this; + } + + /** + * @param string[] $roleIds Roles granted when this option is picked + */ + public function setRoleIds(array $roleIds): self + { + $this->data['role_ids'] = array_values($roleIds); + + return $this; + } + + /** @return ?string[] */ + public function getRoleIds(): ?array + { + return $this->data['role_ids'] ?? null; + } + + /** + * @param string[] $channelIds Channels shown when this option is picked + */ + public function setChannelIds(array $channelIds): self + { + $this->data['channel_ids'] = array_values($channelIds); + + return $this; + } + + /** @return ?string[] */ + public function getChannelIds(): ?array + { + return $this->data['channel_ids'] ?? null; + } + + public function get(): array + { + return $this->data; + } +} diff --git a/tests/Rest/GuildTest.php b/tests/Rest/GuildTest.php index 5b83c5d..9ad670c 100644 --- a/tests/Rest/GuildTest.php +++ b/tests/Rest/GuildTest.php @@ -9,6 +9,11 @@ use Ragnarok\Fenrir\Parts\GuildBan; use Ragnarok\Fenrir\Parts\GuildMember; use Ragnarok\Fenrir\Parts\GuildPreview; +use Ragnarok\Fenrir\Parts\WelcomeScreen; +use Ragnarok\Fenrir\Parts\BulkBanResult; +use Ragnarok\Fenrir\Parts\GuildOnboarding; +use Ragnarok\Fenrir\Rest\Helpers\Guild\ModifyGuildOnboardingBuilder; +use Ragnarok\Fenrir\Rest\Helpers\Guild\ModifyWelcomeScreenBuilder; use Ragnarok\Fenrir\Rest\Guild; use Ragnarok\Fenrir\Rest\Helpers\Guild\ModifyChannelPositionsBuilder; use Tests\Ragnarok\Fenrir\Rest\HttpHelperTestCase; @@ -20,6 +25,59 @@ class GuildTest extends HttpHelperTestCase public static function httpBindingsProvider(): array { return [ + 'Modify welcome screen' => [ + 'method' => 'modifyWelcomeScreen', + 'args' => ['::guild id::', ModifyWelcomeScreenBuilder::new()], + 'mockOptions' => [ + 'method' => 'patch', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => WelcomeScreen::class, + ], + ], + 'Get onboarding' => [ + 'method' => 'getOnboarding', + 'args' => ['::guild id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => GuildOnboarding::class, + ], + ], + 'Modify onboarding' => [ + 'method' => 'modifyOnboarding', + 'args' => ['::guild id::', ModifyGuildOnboardingBuilder::new()], + 'mockOptions' => [ + 'method' => 'put', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => GuildOnboarding::class, + ], + ], + 'Bulk ban' => [ + 'method' => 'bulkBan', + 'args' => ['::guild id::', ['::user a::', '::user b::'], 3600], + 'mockOptions' => [ + 'method' => 'post', + 'return' => (object) [], + ], + 'validationOptions' => [ + 'returnType' => BulkBanResult::class, + ], + ], + 'Get role member counts' => [ + 'method' => 'getRoleMemberCounts', + 'args' => ['::guild id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) [], + ], + 'validationOptions' => [], + ], 'Get guild' => [ 'method' => 'get', 'args' => ['::guild id::'], diff --git a/tests/Rest/Helpers/Guild/OnboardingBuildersTest.php b/tests/Rest/Helpers/Guild/OnboardingBuildersTest.php new file mode 100644 index 0000000..a3410a3 --- /dev/null +++ b/tests/Rest/Helpers/Guild/OnboardingBuildersTest.php @@ -0,0 +1,110 @@ +setEnabled(true) + ->setMode(GuildOnboardingMode::ONBOARDING_ADVANCED) + ->setDefaultChannelIds(['::general::']) + ->addPrompt( + OnboardingPromptBuilder::new() + ->setId('0') + ->setTitle('What brings you here?') + ->setType(OnboardingPromptType::MULTIPLE_CHOICE) + ->setSingleSelect(true) + ->setRequired(true) + ->setInOnboarding(true) + ->addOption( + OnboardingPromptOptionBuilder::new() + ->setId('0') + ->setTitle('Support') + ->setRoleIds(['::support role::']) + ->setChannelIds(['::help channel::']) + ) + ); + + $this->assertEquals([ + 'enabled' => true, + 'mode' => GuildOnboardingMode::ONBOARDING_ADVANCED->value, + 'default_channel_ids' => ['::general::'], + 'prompts' => [ + [ + 'id' => '0', + 'title' => 'What brings you here?', + 'type' => OnboardingPromptType::MULTIPLE_CHOICE->value, + 'single_select' => true, + 'required' => true, + 'in_onboarding' => true, + 'options' => [ + [ + 'id' => '0', + 'title' => 'Support', + 'role_ids' => ['::support role::'], + 'channel_ids' => ['::help channel::'], + ], + ], + ], + ], + ], $onboarding->get()); + } + + /** + * A prompt always carries an options key, even an empty one, because + * Discord treats it as required. + */ + public function testAPromptAlwaysSendsItsOptions(): void + { + $this->assertEquals( + ['id' => '0', 'options' => []], + OnboardingPromptBuilder::new()->setId('0')->get() + ); + } + + public function testPromptsAreOmittedWhenNoneWereAdded(): void + { + $this->assertEquals( + ['enabled' => false], + ModifyGuildOnboardingBuilder::new()->setEnabled(false)->get() + ); + } + + public function testItRejectsASixteenthPrompt(): void + { + $onboarding = ModifyGuildOnboardingBuilder::new(); + + for ($i = 0; $i < ModifyGuildOnboardingBuilder::MAX_PROMPTS; $i++) { + $onboarding->addPrompt(OnboardingPromptBuilder::new()->setId((string) $i)); + } + + $this->expectException(TooManyItemsException::class); + + $onboarding->addPrompt(OnboardingPromptBuilder::new()->setId('one too many')); + } + + public function testAnOptionCanCarryAnEmoji(): void + { + $option = OnboardingPromptOptionBuilder::new() + ->setTitle('Support') + ->setEmoji(emojiName: '::emoji::'); + + $this->assertEquals([ + 'title' => 'Support', + 'emoji_id' => null, + 'emoji_name' => '::emoji::', + ], $option->get()); + } +} From 1636d75702f9034db9670c8e4c012569c24a3cea Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 13:02:25 +0200 Subject: [PATCH 12/13] Add the remaining channel endpoints, and the pins Discord actually uses 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. --- src/Enums/SortingOrder.php | 11 ++ src/Enums/ThreadSearchTagSetting.php | 11 ++ src/Enums/ThreadSortingMode.php | 13 ++ src/Parts/PinnedMessage.php | 16 +++ src/Parts/PinnedMessages.php | 22 +++ src/Parts/ThreadSearchResult.php | 33 +++++ src/Rest/Channel.php | 127 ++++++++++++++++++ .../Helpers/Channel/SearchThreadsBuilder.php | 125 +++++++++++++++++ tests/Rest/ChannelTest.php | 95 +++++++++++++ 9 files changed, 453 insertions(+) create mode 100644 src/Enums/SortingOrder.php create mode 100644 src/Enums/ThreadSearchTagSetting.php create mode 100644 src/Enums/ThreadSortingMode.php create mode 100644 src/Parts/PinnedMessage.php create mode 100644 src/Parts/PinnedMessages.php create mode 100644 src/Parts/ThreadSearchResult.php create mode 100644 src/Rest/Helpers/Channel/SearchThreadsBuilder.php diff --git a/src/Enums/SortingOrder.php b/src/Enums/SortingOrder.php new file mode 100644 index 0000000..a5ec123 --- /dev/null +++ b/src/Enums/SortingOrder.php @@ -0,0 +1,11 @@ + */ public function getPinnedMessages(string $channelId): PromiseInterface @@ -495,6 +501,8 @@ public function getPinnedMessages(string $channelId): PromiseInterface /** * @see https://discord.com/developers/docs/resources/channel#pin-message * + * @deprecated Use pinChannelMessage + * * @return PromiseInterface */ public function pinMessage(string $channelId, string $messageId): PromiseInterface @@ -511,6 +519,8 @@ public function pinMessage(string $channelId, string $messageId): PromiseInterfa /** * @see https://discord.com/developers/docs/resources/channel#unpin-message * + * @deprecated Use unpinChannelMessage + * * @return PromiseInterface */ public function unpinMessage(string $channelId, string $messageId): PromiseInterface @@ -524,6 +534,123 @@ public function unpinMessage(string $channelId, string $messageId): PromiseInter ); } + /** + * The current pins, newest first, with the time each was pinned. + * + * Replaces getPinnedMessages, which hits the endpoint Discord has since + * deprecated and returns bare messages without pagination. + * + * @see https://discord.com/developers/docs/resources/message#get-channel-pins + * + * @param ?string $before ISO8601 timestamp to page backwards from + * @param ?int $limit Up to 50, defaults to 50 + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\PinnedMessages> + */ + public function getChannelPins( + string $channelId, + ?string $before = null, + ?int $limit = null + ): PromiseInterface { + $endpoint = Endpoint::bind(Endpoint::CHANNEL_MESSAGES_PINS, $channelId); + + if (!is_null($before)) { + $endpoint->addQuery('before', $before); + } + + if (!is_null($limit)) { + $endpoint->addQuery('limit', $limit); + } + + return $this->mapPromise( + $this->http->get($endpoint), + PinnedMessages::class + ); + } + + /** + * @see https://discord.com/developers/docs/resources/message#pin-message + * + * @return PromiseInterface + */ + public function pinChannelMessage( + string $channelId, + string $messageId, + ?string $reason = null + ): PromiseInterface { + return $this->http->put( + Endpoint::bind( + Endpoint::CHANNEL_MESSAGES_PIN, + $channelId, + $messageId + ), + null, + $this->getAuditLogReasonHeader($reason) + ); + } + + /** + * @see https://discord.com/developers/docs/resources/message#unpin-message + * + * @return PromiseInterface + */ + public function unpinChannelMessage( + string $channelId, + string $messageId, + ?string $reason = null + ): PromiseInterface { + return $this->http->delete( + Endpoint::bind( + Endpoint::CHANNEL_MESSAGES_PIN, + $channelId, + $messageId + ), + null, + $this->getAuditLogReasonHeader($reason) + ); + } + + /** + * Sets the status shown on a voice channel, or clears it when given null. + * + * @see https://discord.com/developers/docs/resources/channel#modify-channel-voice-status + * + * @return PromiseInterface + */ + public function setVoiceStatus(string $channelId, ?string $status): PromiseInterface + { + return $this->http->put( + Endpoint::bind( + Endpoint::CHANNEL_VOICE_STATUS, + $channelId + ), + ['status' => $status] + ); + } + + /** + * Searches the threads of a forum or media channel. + * + * @see https://discord.com/developers/docs/resources/channel#search-threads + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\ThreadSearchResult> + */ + public function searchThreads( + string $channelId, + ?SearchThreadsBuilder $searchThreadsBuilder = null + ): PromiseInterface { + $endpoint = Endpoint::bind(Endpoint::CHANNEL_THREADS_SEARCH, $channelId); + + foreach ($searchThreadsBuilder?->get() ?? [] as $key => $value) { + $endpoint->addQuery($key, $value); + } + + return $this->mapPromise( + $this->http->get($endpoint), + ThreadSearchResult::class + ); + } + /** * @see https://discord.com/developers/docs/resources/channel#start-thread-from-message * diff --git a/src/Rest/Helpers/Channel/SearchThreadsBuilder.php b/src/Rest/Helpers/Channel/SearchThreadsBuilder.php new file mode 100644 index 0000000..0cdafad --- /dev/null +++ b/src/Rest/Helpers/Channel/SearchThreadsBuilder.php @@ -0,0 +1,125 @@ +data['name'] = $name; + + return $this; + } + + public function getName(): ?string + { + return $this->data['name'] ?? null; + } + + /** + * @var int $slop How much fuzziness to allow when matching the name, 0 to 100 + */ + public function setSlop(int $slop): self + { + $this->data['slop'] = $slop; + + return $this; + } + + public function setMinId(string $minId): self + { + $this->data['min_id'] = $minId; + + return $this; + } + + public function setMaxId(string $maxId): self + { + $this->data['max_id'] = $maxId; + + return $this; + } + + /** + * @param string[] $tags Forum tag ids + */ + public function setTags(array $tags): self + { + $this->data['tag'] = array_values($tags); + + return $this; + } + + /** @return ?string[] */ + public function getTags(): ?array + { + return $this->data['tag'] ?? null; + } + + /** + * Whether a thread has to carry every requested tag or just one of them. + */ + public function setTagSetting(ThreadSearchTagSetting $tagSetting): self + { + $this->data['tag_setting'] = $tagSetting->value; + + return $this; + } + + public function setArchived(bool $archived): self + { + $this->data['archived'] = $archived; + + return $this; + } + + public function setSortBy(ThreadSortingMode $sortBy): self + { + $this->data['sort_by'] = $sortBy->value; + + return $this; + } + + public function setSortOrder(SortingOrder $sortOrder): self + { + $this->data['sort_order'] = $sortOrder->value; + + return $this; + } + + /** + * @var int $limit Between 1 and 25 + */ + public function setLimit(int $limit): self + { + $this->data['limit'] = $limit; + + return $this; + } + + public function setOffset(int $offset): self + { + $this->data['offset'] = $offset; + + return $this; + } + + public function get(): array + { + return $this->data; + } +} diff --git a/tests/Rest/ChannelTest.php b/tests/Rest/ChannelTest.php index b983f6d..be431a4 100644 --- a/tests/Rest/ChannelTest.php +++ b/tests/Rest/ChannelTest.php @@ -9,6 +9,12 @@ use Ragnarok\Fenrir\Parts\Message; use Ragnarok\Fenrir\Parts\ThreadMember; use Ragnarok\Fenrir\Parts\User; +use Ragnarok\Fenrir\Enums\SortingOrder; +use Ragnarok\Fenrir\Enums\ThreadSearchTagSetting; +use Ragnarok\Fenrir\Enums\ThreadSortingMode; +use Ragnarok\Fenrir\Parts\PinnedMessages; +use Ragnarok\Fenrir\Parts\ThreadSearchResult; +use Ragnarok\Fenrir\Rest\Helpers\Channel\SearchThreadsBuilder; use Ragnarok\Fenrir\Rest\Channel; use Ragnarok\Fenrir\Rest\Helpers\Channel\Channel\GuildAnnouncementChannelBuilder; use Ragnarok\Fenrir\Rest\Helpers\Channel\Channel\GuildForumChannelBuilder; @@ -30,6 +36,95 @@ class ChannelTest extends HttpHelperTestCase public static function httpBindingsProvider(): array { return [ + 'Get channel pins' => [ + 'method' => 'getChannelPins', + 'args' => ['::channel id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) ['items' => [], 'has_more' => false], + ], + 'validationOptions' => [ + 'returnType' => PinnedMessages::class, + ], + ], + 'Get channel pins paginated' => [ + 'method' => 'getChannelPins', + 'args' => ['::channel id::', '2026-01-01T00:00:00.000Z', 50], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) ['items' => [], 'has_more' => true], + ], + 'validationOptions' => [ + 'returnType' => PinnedMessages::class, + ], + ], + 'Pin channel message' => [ + 'method' => 'pinChannelMessage', + 'args' => ['::channel id::', '::message id::'], + 'mockOptions' => [ + 'method' => 'put', + 'return' => null, + ], + 'validationOptions' => [], + ], + 'Unpin channel message' => [ + 'method' => 'unpinChannelMessage', + 'args' => ['::channel id::', '::message id::'], + 'mockOptions' => [ + 'method' => 'delete', + 'return' => null, + ], + 'validationOptions' => [], + ], + 'Set voice status' => [ + 'method' => 'setVoiceStatus', + 'args' => ['::channel id::', 'Playing chess'], + 'mockOptions' => [ + 'method' => 'put', + 'return' => null, + ], + 'validationOptions' => [], + ], + 'Clear voice status' => [ + 'method' => 'setVoiceStatus', + 'args' => ['::channel id::', null], + 'mockOptions' => [ + 'method' => 'put', + 'return' => null, + ], + 'validationOptions' => [], + ], + 'Search threads' => [ + 'method' => 'searchThreads', + 'args' => ['::channel id::'], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) ['threads' => [], 'members' => [], 'first_messages' => [], 'has_more' => false, 'total_results' => 0], + ], + 'validationOptions' => [ + 'returnType' => ThreadSearchResult::class, + ], + ], + 'Search threads filtered' => [ + 'method' => 'searchThreads', + 'args' => [ + '::channel id::', + SearchThreadsBuilder::new() + ->setName('bug') + ->setTags(['::tag a::']) + ->setTagSetting(ThreadSearchTagSetting::MATCH_ALL) + ->setSortBy(ThreadSortingMode::RELEVANCE) + ->setSortOrder(SortingOrder::DESC) + ->setLimit(25), + ], + 'mockOptions' => [ + 'method' => 'get', + 'return' => (object) ['threads' => [], 'members' => [], 'first_messages' => [], 'has_more' => false, 'total_results' => 0], + ], + 'validationOptions' => [ + 'returnType' => ThreadSearchResult::class, + ], + ], 'Get channel' => [ 'method' => 'get', 'args' => ['::channel id::'], From a21d9fac34863e3a33752db9839c8f9f9fe86ea3 Mon Sep 17 00:00:00 2001 From: mikield Date: Sat, 22 Aug 2026 13:28:39 +0200 Subject: [PATCH 13/13] Send permission bit fields as decimal, not binary 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. --- src/Rest/Helpers/Channel/EditPermissionsBuilder.php | 8 ++++---- src/Rest/Helpers/Command/CommandBuilder.php | 4 ++-- .../Helpers/Channel/EditPermissionsBuilderTest.php | 11 +++++++++-- tests/Rest/Helpers/Command/CommandBuilderTest.php | 8 +++++++- 4 files changed, 22 insertions(+), 9 deletions(-) diff --git a/src/Rest/Helpers/Channel/EditPermissionsBuilder.php b/src/Rest/Helpers/Channel/EditPermissionsBuilder.php index cd8ddf4..d49f908 100644 --- a/src/Rest/Helpers/Channel/EditPermissionsBuilder.php +++ b/src/Rest/Helpers/Channel/EditPermissionsBuilder.php @@ -37,7 +37,7 @@ public function getOverwriteId(): ?string public function setAllow(Bitwise $allow): self { - $this->data['allow'] = $allow->getBitSet(); + $this->data['allow'] = (string) $allow->get(); return $this; } @@ -45,13 +45,13 @@ public function setAllow(Bitwise $allow): self public function getAllow(): ?Bitwise { return isset($this->data['allow']) - ? Bitwise::fromBitSet($this->data['allow']) + ? new Bitwise((int) $this->data['allow']) : null; } public function setDeny(Bitwise $deny): self { - $this->data['deny'] = $deny->getBitSet(); + $this->data['deny'] = (string) $deny->get(); return $this; } @@ -59,7 +59,7 @@ public function setDeny(Bitwise $deny): self public function getDeny(): ?Bitwise { return isset($this->data['deny']) - ? Bitwise::fromBitSet($this->data['deny']) + ? new Bitwise((int) $this->data['deny']) : null; } diff --git a/src/Rest/Helpers/Command/CommandBuilder.php b/src/Rest/Helpers/Command/CommandBuilder.php index 31f9665..72915f9 100644 --- a/src/Rest/Helpers/Command/CommandBuilder.php +++ b/src/Rest/Helpers/Command/CommandBuilder.php @@ -134,7 +134,7 @@ public function getOptions(): ?array */ public function setDefaultMemberPermissions(Bitwise $permissions): self { - $this->data['default_member_permissions'] = $permissions->getBitSet(); + $this->data['default_member_permissions'] = (string) $permissions->get(); return $this; } @@ -145,7 +145,7 @@ public function setDefaultMemberPermissions(Bitwise $permissions): self public function getDefaultMemberPermissions(): ?Bitwise { return isset($this->data['default_member_permissions']) - ? Bitwise::fromBitSet($this->data['default_member_permissions']) + ? new Bitwise((int) $this->data['default_member_permissions']) : null; } diff --git a/tests/Rest/Helpers/Channel/EditPermissionsBuilderTest.php b/tests/Rest/Helpers/Channel/EditPermissionsBuilderTest.php index 62feaf4..752d847 100644 --- a/tests/Rest/Helpers/Channel/EditPermissionsBuilderTest.php +++ b/tests/Rest/Helpers/Channel/EditPermissionsBuilderTest.php @@ -39,7 +39,7 @@ public function testSetAllow(): void $this->assertNull($builder->getAllow()); - $bitwise = new Bitwise( + $bitwise = Bitwise::from( 1 << 1, 1 << 2, 1 << 3 @@ -48,6 +48,7 @@ public function testSetAllow(): void $builder->setAllow($bitwise); $this->assertEquals($bitwise->get(), $builder->getAllow()->get()); + $this->assertSame('14', $builder->get()['allow']); } public function testSetDeny(): void @@ -56,7 +57,7 @@ public function testSetDeny(): void $this->assertNull($builder->getDeny()); - $bitwise = new Bitwise( + $bitwise = Bitwise::from( 1 << 1, 1 << 2, 1 << 3 @@ -65,5 +66,11 @@ public function testSetDeny(): void $builder->setDeny($bitwise); $this->assertEquals($bitwise->get(), $builder->getDeny()->get()); + + /* + * A decimal bit field, as Discord reads it; the binary representation + * would be read back as a different set of permissions. + */ + $this->assertSame('14', $builder->get()['deny']); } } diff --git a/tests/Rest/Helpers/Command/CommandBuilderTest.php b/tests/Rest/Helpers/Command/CommandBuilderTest.php index 9554d9d..bd1a07d 100644 --- a/tests/Rest/Helpers/Command/CommandBuilderTest.php +++ b/tests/Rest/Helpers/Command/CommandBuilderTest.php @@ -92,7 +92,13 @@ public function testSetDefaultMemberPermissions(): void $commandBuilder->setDefaultMemberPermissions($permissions); $this->assertEquals($permissions->get(), $commandBuilder->getDefaultMemberPermissions()->get()); - $this->assertEquals($permissions->getBitSet(), $commandBuilder->get()['default_member_permissions']); + + /* + * Discord reads this as a decimal bit field. Sending the binary + * representation would be read back as an entirely different, and + * much larger, set of permissions. + */ + $this->assertSame('6', $commandBuilder->get()['default_member_permissions']); } public function testSetDmPermission(): void