diff --git a/src/Component/Modal/Checkbox.php b/src/Component/Modal/Checkbox.php new file mode 100644 index 00000000..e67c9829 --- /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 00000000..648150a5 --- /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 00000000..4b4ca694 --- /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 00000000..ec7f5bce --- /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 00000000..b956968b --- /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 00000000..2a441e0d --- /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/Component/V2/Container.php b/src/Component/V2/Container.php new file mode 100644 index 00000000..a716dfc8 --- /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 00000000..3bb16591 --- /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 00000000..b2dcfc9b --- /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 00000000..0f6f1292 --- /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 00000000..41796b46 --- /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 00000000..4800c4cc --- /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 00000000..1315e859 --- /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 00000000..755f9283 --- /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 00000000..f6692774 --- /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/Constants/Events.php b/src/Constants/Events.php index 1dc57f51..7176ea91 100644 --- a/src/Constants/Events.php +++ b/src/Constants/Events.php @@ -27,10 +27,16 @@ 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'; + 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'; @@ -54,6 +60,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'; @@ -78,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'; @@ -109,10 +125,17 @@ 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, + 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, @@ -137,6 +160,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, @@ -160,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/EntitlementOwnerType.php b/src/Enums/EntitlementOwnerType.php new file mode 100644 index 00000000..ae00daaf --- /dev/null +++ b/src/Enums/EntitlementOwnerType.php @@ -0,0 +1,14 @@ +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/Helpers/InteractionCallbackBuilder.php b/src/Interaction/Helpers/InteractionCallbackBuilder.php index 2b8ec6a1..63e1eb46 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 00000000..a3f4e4f6 --- /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/src/Interaction/ModalSubmitInteraction.php b/src/Interaction/ModalSubmitInteraction.php new file mode 100644 index 00000000..dbceb9bd --- /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 4abecf78..f2b3ec33 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/BulkBanResult.php b/src/Parts/BulkBanResult.php new file mode 100644 index 00000000..02ab6512 --- /dev/null +++ b/src/Parts/BulkBanResult.php @@ -0,0 +1,16 @@ + */ 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/Entitlement.php b/src/Rest/Entitlement.php new file mode 100644 index 00000000..3651a66a --- /dev/null +++ b/src/Rest/Entitlement.php @@ -0,0 +1,125 @@ + + */ + 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/GlobalCommand.php b/src/Rest/GlobalCommand.php index 63ea5bd6..498b2a78 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/Guild.php b/src/Rest/Guild.php index 34886b5e..c0057f5f 100644 --- a/src/Rest/Guild.php +++ b/src/Rest/Guild.php @@ -22,6 +22,10 @@ use Ragnarok\Fenrir\Parts\Widget; use Ragnarok\Fenrir\Parts\WidgetSettings; use Ragnarok\Fenrir\Rest\Helpers\Guild\ModifyChannelPositionsBuilder; +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 React\Promise\PromiseInterface; /** @@ -906,4 +910,130 @@ public function modifyUserVoiceState(string $guildId, string $userId, array $par $params, ); } + + /** + * @see https://discord.com/developers/docs/resources/guild#modify-guild-welcome-screen + * + * @return PromiseInterface<\Ragnarok\Fenrir\Parts\WelcomeScreen> + */ + 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/GuildCommand.php b/src/Rest/GuildCommand.php index 07dbb783..2e5ce40b 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/src/Rest/Helpers/Channel/ComponentBuilder.php b/src/Rest/Helpers/Channel/ComponentBuilder.php index 6889db55..e5089df5 100644 --- a/src/Rest/Helpers/Channel/ComponentBuilder.php +++ b/src/Rest/Helpers/Channel/ComponentBuilder.php @@ -4,6 +4,8 @@ namespace Ragnarok\Fenrir\Rest\Helpers\Channel; +use Ragnarok\Fenrir\Component\Component; +use Ragnarok\Fenrir\Enums\MessageComponentType; use Ragnarok\Fenrir\Exceptions\Rest\Helpers\ComponentBuilder\TooManyRowsException; use Ragnarok\Fenrir\Rest\Helpers\GetNew; @@ -14,15 +16,25 @@ class ComponentBuilder { use GetNew; - /** @var ComponentRowBuilder[] */ - private array $rows = []; + /** + * Rows and top level components in the order they were added, since + * components v2 lets both sit alongside each other. + * + * @var array + */ + 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/src/Rest/Helpers/Channel/EditPermissionsBuilder.php b/src/Rest/Helpers/Channel/EditPermissionsBuilder.php index cd8ddf45..d49f9087 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/Channel/Message/SetPoll.php b/src/Rest/Helpers/Channel/Message/SetPoll.php new file mode 100644 index 00000000..90ab4460 --- /dev/null +++ b/src/Rest/Helpers/Channel/Message/SetPoll.php @@ -0,0 +1,22 @@ +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 f5692bc3..0f453efe 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 00000000..e42614be --- /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/Helpers/Channel/SearchThreadsBuilder.php b/src/Rest/Helpers/Channel/SearchThreadsBuilder.php new file mode 100644 index 00000000..0cdafad8 --- /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/src/Rest/Helpers/Command/CommandBuilder.php b/src/Rest/Helpers/Command/CommandBuilder.php index 31f9665f..72915f91 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/src/Rest/Helpers/Entitlement/GetEntitlementsBuilder.php b/src/Rest/Helpers/Entitlement/GetEntitlementsBuilder.php new file mode 100644 index 00000000..579ee8b5 --- /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/Helpers/GetBase64Sound.php b/src/Rest/Helpers/GetBase64Sound.php new file mode 100644 index 00000000..8d7ed3f3 --- /dev/null +++ b/src/Rest/Helpers/GetBase64Sound.php @@ -0,0 +1,15 @@ +value . ';base64,' . base64_encode($content); + } +} diff --git a/src/Rest/Helpers/Guild/ModifyGuildOnboardingBuilder.php b/src/Rest/Helpers/Guild/ModifyGuildOnboardingBuilder.php new file mode 100644 index 00000000..1a65f704 --- /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 00000000..88524feb --- /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 00000000..01dc8d92 --- /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 00000000..b47dd533 --- /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/src/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilder.php b/src/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilder.php new file mode 100644 index 00000000..0277be56 --- /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 00000000..8b9cb920 --- /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/Helpers/Subscription/GetSubscriptionsBuilder.php b/src/Rest/Helpers/Subscription/GetSubscriptionsBuilder.php new file mode 100644 index 00000000..d8a48659 --- /dev/null +++ b/src/Rest/Helpers/Subscription/GetSubscriptionsBuilder.php @@ -0,0 +1,73 @@ +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/Poll.php b/src/Rest/Poll.php new file mode 100644 index 00000000..0b268a5a --- /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 71c75379..95693653 100644 --- a/src/Rest/Rest.php +++ b/src/Rest/Rest.php @@ -15,12 +15,17 @@ 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; public readonly GuildTemplate $guildTemplate; 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; @@ -38,12 +43,17 @@ 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); $this->guildTemplate = new GuildTemplate(...$args); $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 00000000..2ddb1eec --- /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/Soundboard.php b/src/Rest/Soundboard.php new file mode 100644 index 00000000..d89f47c3 --- /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/src/Rest/Subscription.php b/src/Rest/Subscription.php new file mode 100644 index 00000000..160f9915 --- /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/Component/Modal/ModalComponentsTest.php b/tests/Component/Modal/ModalComponentsTest.php new file mode 100644 index 00000000..b773f6d4 --- /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/Component/V2/ComponentsV2Test.php b/tests/Component/V2/ComponentsV2Test.php new file mode 100644 index 00000000..bd53d166 --- /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/Interaction/ComponentInteractionTest.php b/tests/Interaction/ComponentInteractionTest.php new file mode 100644 index 00000000..bcc6b125 --- /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/Helpers/ModalBuilderTest.php b/tests/Interaction/Helpers/ModalBuilderTest.php new file mode 100644 index 00000000..6c226ee7 --- /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'])); + } +} diff --git a/tests/Interaction/ModalSubmitInteractionTest.php b/tests/Interaction/ModalSubmitInteractionTest.php new file mode 100644 index 00000000..c735cc9f --- /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 8df86304..b5919efe 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); diff --git a/tests/Rest/ChannelTest.php b/tests/Rest/ChannelTest.php index b983f6d4..be431a46 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::'], diff --git a/tests/Rest/EntitlementTest.php b/tests/Rest/EntitlementTest.php new file mode 100644 index 00000000..f28888f0 --- /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/GlobalCommandTest.php b/tests/Rest/GlobalCommandTest.php index 490f1d65..42f8cb11 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 66cf182f..32064468 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 + ], + ], ]; } } diff --git a/tests/Rest/GuildTest.php b/tests/Rest/GuildTest.php index 5b83c5dd..9ad670ca 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/Channel/ComponentBuilderV2Test.php b/tests/Rest/Helpers/Channel/ComponentBuilderV2Test.php new file mode 100644 index 00000000..eb0bf83a --- /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']); + } +} diff --git a/tests/Rest/Helpers/Channel/EditPermissionsBuilderTest.php b/tests/Rest/Helpers/Channel/EditPermissionsBuilderTest.php index 62feaf44..752d8470 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/Channel/MessageBuilderTest.php b/tests/Rest/Helpers/Channel/MessageBuilderTest.php index 873c92c6..3b0f60ba 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 00000000..135c9299 --- /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/Helpers/Command/CommandBuilderTest.php b/tests/Rest/Helpers/Command/CommandBuilderTest.php index 9554d9d5..bd1a07d3 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 diff --git a/tests/Rest/Helpers/Entitlement/GetEntitlementsBuilderTest.php b/tests/Rest/Helpers/Entitlement/GetEntitlementsBuilderTest.php new file mode 100644 index 00000000..c0bef68d --- /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()); + } +} diff --git a/tests/Rest/Helpers/Guild/OnboardingBuildersTest.php b/tests/Rest/Helpers/Guild/OnboardingBuildersTest.php new file mode 100644 index 00000000..a3410a3e --- /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()); + } +} diff --git a/tests/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilderTest.php b/tests/Rest/Helpers/Soundboard/CreateSoundboardSoundBuilderTest.php new file mode 100644 index 00000000..2d082a0f --- /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 00000000..5bdb3c25 --- /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/PollTest.php b/tests/Rest/PollTest.php new file mode 100644 index 00000000..9bb543f6 --- /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, + ], + ], + ]; + } +} diff --git a/tests/Rest/SkuTest.php b/tests/Rest/SkuTest.php new file mode 100644 index 00000000..91c73eb8 --- /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/SoundboardTest.php b/tests/Rest/SoundboardTest.php new file mode 100644 index 00000000..163435d3 --- /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' => [], + ], + ]; + } +} diff --git a/tests/Rest/SubscriptionTest.php b/tests/Rest/SubscriptionTest.php new file mode 100644 index 00000000..a8363ba7 --- /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, + ], + ], + ]; + } +}