From f5bba5d1d9b9ad8227c084742507079cd26722a2 Mon Sep 17 00:00:00 2001 From: Hannes <31671206+xJuvi@users.noreply.github.com> Date: Tue, 18 Aug 2026 08:36:51 +0200 Subject: [PATCH] add rcs params This PR adds the possibility to set all other rcs options (not only text messages) and fixes the fallback options (mode or json object for sms message) --- src/Resource/Rcs/RcsCalendarEventParams.php | 21 +++ src/Resource/Rcs/RcsCardOrientation.php | 9 ++ src/Resource/Rcs/RcsCarouselParams.php | 48 ++++++ src/Resource/Rcs/RcsCarouselWidth.php | 9 ++ src/Resource/Rcs/RcsFallbackParams.php | 51 +++++++ src/Resource/Rcs/RcsFileHeight.php | 10 ++ src/Resource/Rcs/RcsFileParams.php | 80 ++++++++++ src/Resource/Rcs/RcsLocationParams.php | 20 +++ src/Resource/Rcs/RcsParams.php | 105 ++++++++++++- src/Resource/Rcs/RcsRichcardParams.php | 97 ++++++++++++ src/Resource/Rcs/RcsSuggestionParams.php | 112 ++++++++++++++ src/Resource/Rcs/RcsSuggestionType.php | 13 ++ .../Rcs/RcsThumbnailImageAlignment.php | 9 ++ src/Resource/Rcs/RcsValidator.php | 133 +++++++++++++++- src/Resource/Rcs/RcsWebviewMode.php | 10 ++ tests/RcsParamsTest.php | 144 ++++++++++++++++++ 16 files changed, 860 insertions(+), 11 deletions(-) create mode 100644 src/Resource/Rcs/RcsCalendarEventParams.php create mode 100644 src/Resource/Rcs/RcsCardOrientation.php create mode 100644 src/Resource/Rcs/RcsCarouselParams.php create mode 100644 src/Resource/Rcs/RcsCarouselWidth.php create mode 100644 src/Resource/Rcs/RcsFallbackParams.php create mode 100644 src/Resource/Rcs/RcsFileHeight.php create mode 100644 src/Resource/Rcs/RcsFileParams.php create mode 100644 src/Resource/Rcs/RcsLocationParams.php create mode 100644 src/Resource/Rcs/RcsRichcardParams.php create mode 100644 src/Resource/Rcs/RcsSuggestionParams.php create mode 100644 src/Resource/Rcs/RcsSuggestionType.php create mode 100644 src/Resource/Rcs/RcsThumbnailImageAlignment.php create mode 100644 src/Resource/Rcs/RcsWebviewMode.php create mode 100644 tests/RcsParamsTest.php diff --git a/src/Resource/Rcs/RcsCalendarEventParams.php b/src/Resource/Rcs/RcsCalendarEventParams.php new file mode 100644 index 0000000..6d64f0b --- /dev/null +++ b/src/Resource/Rcs/RcsCalendarEventParams.php @@ -0,0 +1,21 @@ +richcards = $richcards; + } + + public function getWidth(): RcsCarouselWidth + { + return $this->width; + } + + public function setWidth(RcsCarouselWidth $width): self + { + $this->width = $width; + return $this; + } + + public function getRichcards(): array + { + return $this->richcards; + } + + public function addRichcards(RcsRichcardParams ...$richcards): self + { + array_push($this->richcards, ...$richcards); + return $this; + } + + public function toArray(): array + { + return [ + 'width' => $this->width->value, + 'richcards' => array_map(fn(RcsRichcardParams $v) => $v->toArray(), $this->richcards), + ]; + } +} diff --git a/src/Resource/Rcs/RcsCarouselWidth.php b/src/Resource/Rcs/RcsCarouselWidth.php new file mode 100644 index 0000000..7a6f96f --- /dev/null +++ b/src/Resource/Rcs/RcsCarouselWidth.php @@ -0,0 +1,9 @@ + $this->type->value, + 'text' => $this->text, + 'from' => $this->from, + ]; + } + + public function getType(): RcsFallbackType + { + return $this->type; + } + + public function getText(): ?string + { + return $this->text; + } + + public function setText(?string $text): self + { + $this->text = $text; + return $this; + } + + public function getFrom(): ?string + { + return $this->from; + } + + public function setFrom(?string $from): self + { + $this->from = $from; + return $this; + } +} diff --git a/src/Resource/Rcs/RcsFileHeight.php b/src/Resource/Rcs/RcsFileHeight.php new file mode 100644 index 0000000..e02be61 --- /dev/null +++ b/src/Resource/Rcs/RcsFileHeight.php @@ -0,0 +1,10 @@ +height) { + $arr['height'] = $this->height->value; + } + + return $arr; + } + + public function getHeight(): ?RcsFileHeight + { + return $this->height; + } + + public function setHeight(?RcsFileHeight $height): self + { + $this->height = $height; + return $this; + } + + public function getFileUrl(): ?string + { + return $this->fileUrl; + } + + public function setFileUrl(?string $fileUrl): self + { + $this->fileUrl = $fileUrl; + return $this; + } + + public function getFileContents(): ?string + { + return $this->fileContents; + } + + public function setFileContents(?string $fileContents): self + { + $this->fileContents = $fileContents; + return $this; + } + + public function getThumbnailUrl(): ?string + { + return $this->thumbnailUrl; + } + + public function setThumbnailUrl(?string $thumbnailUrl): self + { + $this->thumbnailUrl = $thumbnailUrl; + return $this; + } + + public function getThumbnailContents(): ?string + { + return $this->thumbnailContents; + } + + public function setThumbnailContents(?string $thumbnailContents): self + { + $this->thumbnailContents = $thumbnailContents; + return $this; + } +} diff --git a/src/Resource/Rcs/RcsLocationParams.php b/src/Resource/Rcs/RcsLocationParams.php new file mode 100644 index 0000000..ab65ff1 --- /dev/null +++ b/src/Resource/Rcs/RcsLocationParams.php @@ -0,0 +1,20 @@ +to = $to; } + public static function carousel(RcsCarouselParams $carousel, string $to): self + { + return (new self('', $to))->setCarousel($carousel); + } + + public static function richcard(RcsRichcardParams $richcard, string $to): self + { + return (new self('', $to))->setRichcard($richcard); + } + + public static function file(RcsFileParams $file, string $to): self + { + return (new self('', $to))->setFile($file); + } + public function getTo(): string { return $this->to; @@ -108,17 +128,92 @@ public function setTTL(?int $ttl): self public function toArray(): array { $arr = get_object_vars($this); - if ($this->delay) $arr['delay'] = $this->delay->format('Y-m-d h:i'); - if ($this->fallback) $arr['fallback'] = $this->fallback->value; + + if ($this->delay) { + $arr['delay'] = $this->delay->format('Y-m-d h:i'); + } + + if ($this->fallback instanceof RcsFallbackType) { + $arr['fallback'] = $this->fallback->value; + } + + if ($this->fallback instanceof RcsFallbackParams) { + $arr['fallback'] = $this->fallback->toArray(); + } + + if ($this->carousel) { + $arr['carousel'] = $this->carousel->toArray(); + } + + if ($this->richcard) { + $arr['richcard'] = $this->richcard->toArray(); + } + + if ($this->file) { + $arr['file'] = $this->file->toArray(); + } + + if (is_array($this->suggestions)) { + $arr['suggestions'] = array_map(fn(RcsSuggestionParams $v) => $v->toArray(), $this->suggestions); + } + + if ('' === $this->text) { + unset($arr['text']); + } + return $arr; } - public function getFallback(): ?RcsFallbackType { + public function getFallback(): RcsFallbackType|RcsFallbackParams|null { return $this->fallback; } - public function setFallback(?RcsFallbackType $fallback): self { + public function setFallback(RcsFallbackType|RcsFallbackParams|null $fallback): self { $this->fallback = $fallback; return $this; } + + public function getCarousel(): ?RcsCarouselParams + { + return $this->carousel; + } + + public function setCarousel(?RcsCarouselParams $carousel): self + { + $this->carousel = $carousel; + return $this; + } + + public function getRichcard(): ?RcsRichcardParams + { + return $this->richcard; + } + + public function setRichcard(?RcsRichcardParams $richcard): self + { + $this->richcard = $richcard; + return $this; + } + + public function getFile(): ?RcsFileParams + { + return $this->file; + } + + public function setFile(?RcsFileParams $file): self + { + $this->file = $file; + return $this; + } + + public function getSuggestions(): ?array + { + return $this->suggestions; + } + + public function setSuggestions(RcsSuggestionParams ...$suggestions): self + { + $this->suggestions = $suggestions; + return $this; + } } diff --git a/src/Resource/Rcs/RcsRichcardParams.php b/src/Resource/Rcs/RcsRichcardParams.php new file mode 100644 index 0000000..0b047a7 --- /dev/null +++ b/src/Resource/Rcs/RcsRichcardParams.php @@ -0,0 +1,97 @@ +orientation) { + $arr['orientation'] = $this->orientation->value; + } + + if ($this->thumbnailImageAlignment) { + $arr['thumbnailImageAlignment'] = $this->thumbnailImageAlignment->value; + } + + if ($this->file) { + $arr['file'] = $this->file->toArray(); + } + + if (is_array($this->suggestions)) { + $arr['suggestions'] = array_map(fn(RcsSuggestionParams $v) => $v->toArray(), $this->suggestions); + } + + return $arr; + } + + public function getTitle(): string + { + return $this->title; + } + + public function getDescription(): string + { + return $this->description; + } + + public function getOrientation(): ?RcsCardOrientation + { + return $this->orientation; + } + + public function setOrientation(?RcsCardOrientation $orientation): self + { + $this->orientation = $orientation; + return $this; + } + + public function getThumbnailImageAlignment(): ?RcsThumbnailImageAlignment + { + return $this->thumbnailImageAlignment; + } + + public function setThumbnailImageAlignment(?RcsThumbnailImageAlignment $thumbnailImageAlignment): self + { + $this->thumbnailImageAlignment = $thumbnailImageAlignment; + return $this; + } + + public function getFile(): ?RcsFileParams + { + return $this->file; + } + + public function setFile(?RcsFileParams $file): self + { + $this->file = $file; + return $this; + } + + public function getSuggestions(): ?array + { + return $this->suggestions; + } + + public function setSuggestions(RcsSuggestionParams ...$suggestions): self + { + $this->suggestions = $suggestions; + return $this; + } +} diff --git a/src/Resource/Rcs/RcsSuggestionParams.php b/src/Resource/Rcs/RcsSuggestionParams.php new file mode 100644 index 0000000..e9d8062 --- /dev/null +++ b/src/Resource/Rcs/RcsSuggestionParams.php @@ -0,0 +1,112 @@ +type->value; + + if ($this->location) { + $arr['location'] = $this->location->toArray(); + } + + if ($this->calendarEvent) { + $arr['calendarEvent'] = $this->calendarEvent->toArray(); + } + + if ($this->webviewMode) { + $arr['webviewMode'] = $this->webviewMode->value; + } + + return $arr; + } + + public function getType(): RcsSuggestionType + { + return $this->type; + } + + public function getText(): string + { + return $this->text; + } + + public function getPostbackData(): string + { + return $this->postbackData; + } + + public function getPhoneNumber(): ?string + { + return $this->phoneNumber; + } + + public function setPhoneNumber(?string $phoneNumber): self + { + $this->phoneNumber = $phoneNumber; + return $this; + } + + public function getLocation(): ?RcsLocationParams + { + return $this->location; + } + + public function setLocation(?RcsLocationParams $location): self + { + $this->location = $location; + return $this; + } + + public function getCalendarEvent(): ?RcsCalendarEventParams + { + return $this->calendarEvent; + } + + public function setCalendarEvent(?RcsCalendarEventParams $calendarEvent): self + { + $this->calendarEvent = $calendarEvent; + return $this; + } + + public function getUrl(): ?string + { + return $this->url; + } + + public function setUrl(?string $url): self + { + $this->url = $url; + return $this; + } + + public function getWebviewMode(): ?RcsWebviewMode + { + return $this->webviewMode; + } + + public function setWebviewMode(?RcsWebviewMode $webviewMode): self + { + $this->webviewMode = $webviewMode; + return $this; + } +} diff --git a/src/Resource/Rcs/RcsSuggestionType.php b/src/Resource/Rcs/RcsSuggestionType.php new file mode 100644 index 0000000..29784ee --- /dev/null +++ b/src/Resource/Rcs/RcsSuggestionType.php @@ -0,0 +1,13 @@ +delay(); + $this->fallback(); $this->foreign_id(); $this->from(); $this->label(); - $this->text(); + $this->content(); + $this->suggestions(); $this->to(); $this->ttl(); } @@ -35,6 +40,19 @@ public function delay(): void { throw new InvalidOptionalArgumentException('Delay must be a value from the future'); } + /** @throws InvalidRequiredArgumentException */ + public function fallback(): void { + $fallback = $this->params->getFallback(); + + if (!$fallback instanceof RcsFallbackParams) { + return; + } + + if (RcsFallbackType::SMS === $fallback->getType() && '' === trim((string)$fallback->getText())) { + throw new InvalidRequiredArgumentException('An SMS fallback object requires text.'); + } + } + /** @throws InvalidOptionalArgumentException */ public function foreign_id(): void { $foreignId = $this->params->getForeignId(); @@ -81,14 +99,42 @@ public function label(): void { } /** @throws InvalidRequiredArgumentException */ - public function text(): void { - $text = $this->params->getText() ?? ''; + public function content(): void { + $hasText = '' !== trim($this->params->getText() ?? ''); + $hasCarousel = null !== $this->params->getCarousel(); + $hasRichcard = null !== $this->params->getRichcard(); + $hasFile = null !== $this->params->getFile(); + + $setContentTypes = 0; + foreach ([$hasText, $hasCarousel, $hasRichcard, $hasFile] as $set) { + if ($set) { + ++$setContentTypes; + } + } - $length = strlen($text); + if (0 === $setContentTypes) { + throw new InvalidRequiredArgumentException( + 'You must provide one content type: text, carousel, richcard or file.'); + } - if (!$length) { + if ($setContentTypes > 1) { throw new InvalidRequiredArgumentException( - 'You cannot send an empty message.'); + 'Only one content type is allowed: text, carousel, richcard or file.'); + } + + if ($hasCarousel) { + $carousel = $this->params->getCarousel(); + foreach ($carousel->getRichcards() as $richcard) { + $this->validateRichcardSuggestions($richcard); + } + } + + if ($hasRichcard) { + $this->validateRichcardSuggestions($this->params->getRichcard()); + } + + if ($hasFile) { + $this->validateFile($this->params->getFile()); } } @@ -128,4 +174,79 @@ public function ttl(): void { "ttl may not exceed $max."); } } + + /** @throws InvalidRequiredArgumentException */ + public function suggestions(): void { + $suggestions = $this->params->getSuggestions(); + + if (null === $suggestions) { + return; + } + + if (count($suggestions) > self::MAX_ROOT_SUGGESTIONS) { + throw new InvalidRequiredArgumentException( + 'RCS messages support at most 11 root-level suggestions.'); + } + + foreach ($suggestions as $suggestion) { + $this->validateSuggestion($suggestion); + } + } + + /** @throws InvalidRequiredArgumentException */ + private function validateRichcardSuggestions(?RcsRichcardParams $richcard): void { + if (null === $richcard) { + return; + } + + $suggestions = $richcard->getSuggestions(); + if (null === $suggestions) { + return; + } + + if (count($suggestions) > self::MAX_RICHCARD_SUGGESTIONS) { + throw new InvalidRequiredArgumentException( + 'A richcard supports at most 4 suggestions.'); + } + + foreach ($suggestions as $suggestion) { + $this->validateSuggestion($suggestion); + } + } + + /** @throws InvalidRequiredArgumentException */ + private function validateSuggestion(RcsSuggestionParams $suggestion): void { + $type = $suggestion->getType(); + + if (RcsSuggestionType::DIAL === $type && '' === trim((string)$suggestion->getPhoneNumber())) { + throw new InvalidRequiredArgumentException('A dial suggestion requires phoneNumber.'); + } + + if (RcsSuggestionType::VIEW_LOCATION === $type && null === $suggestion->getLocation()) { + throw new InvalidRequiredArgumentException('A viewLocation suggestion requires location.'); + } + + if (RcsSuggestionType::CREATE_CALENDAR_EVENT === $type && null === $suggestion->getCalendarEvent()) { + throw new InvalidRequiredArgumentException('A createCalendarEvent suggestion requires calendarEvent.'); + } + + if (RcsSuggestionType::OPEN_URL === $type && '' === trim((string)$suggestion->getUrl())) { + throw new InvalidRequiredArgumentException('An openUrl suggestion requires url.'); + } + } + + /** @throws InvalidRequiredArgumentException */ + private function validateFile(?RcsFileParams $file): void { + if (null === $file) { + return; + } + + $hasFileUrl = '' !== trim((string)$file->getFileUrl()); + $hasFileContents = '' !== trim((string)$file->getFileContents()); + + if (!$hasFileUrl && !$hasFileContents) { + throw new InvalidRequiredArgumentException( + 'A file content requires either fileUrl or fileContents.'); + } + } } diff --git a/src/Resource/Rcs/RcsWebviewMode.php b/src/Resource/Rcs/RcsWebviewMode.php new file mode 100644 index 0000000..1f428a6 --- /dev/null +++ b/src/Resource/Rcs/RcsWebviewMode.php @@ -0,0 +1,10 @@ +setFile( + (new RcsFileParams) + ->setHeight(RcsFileHeight::TALL) + ->setFileUrl('https://example.com/dog.jpg') + ->setThumbnailUrl('https://example.com/dog-thumb.jpg') + ) + ->setSuggestions( + new RcsSuggestionParams(RcsSuggestionType::REPLY, 'Cute', 'dog_cute') + ); + + $params = RcsParams::carousel( + new RcsCarouselParams(RcsCarouselWidth::SMALL, $card), + '491716992343' + ); + + $payload = $params->toArray(); + + $this->assertArrayHasKey('carousel', $payload); + $this->assertArrayNotHasKey('text', $payload); + $this->assertSame('SMALL', $payload['carousel']['width']); + $this->assertCount(1, $payload['carousel']['richcards']); + $this->assertSame('Dog', $payload['carousel']['richcards'][0]['title']); + } + + public function testToArraySupportsSuggestionsForTextMessage(): void + { + $params = (new RcsParams('Visit our website', '491716992343')) + ->setSuggestions( + (new RcsSuggestionParams(RcsSuggestionType::OPEN_URL, 'Open', 'open_site')) + ->setUrl('https://example.com') + ->setWebviewMode(RcsWebviewMode::FULL) + ); + + $payload = $params->toArray(); + + $this->assertSame('Visit our website', $payload['text']); + $this->assertArrayHasKey('suggestions', $payload); + $this->assertSame('openUrl', $payload['suggestions'][0]['type']); + $this->assertSame('https://example.com', $payload['suggestions'][0]['url']); + $this->assertSame('full', $payload['suggestions'][0]['webviewMode']); + } + + public function testValidateFailsWhenNoContentTypeIsProvided(): void + { + $this->expectException(InvalidRequiredArgumentException::class); + + (new RcsValidator(new RcsParams('', '491716992343')))->validate(); + } + + public function testValidateFailsWhenMultipleContentTypesAreProvided(): void + { + $this->expectException(InvalidRequiredArgumentException::class); + + $params = (new RcsParams('Text content', '491716992343')) + ->setFile((new RcsFileParams)->setFileUrl('https://example.com/file.pdf')); + + (new RcsValidator($params))->validate(); + } + + public function testValidateFailsWhenTooManyRootSuggestionsAreProvided(): void + { + $this->expectException(InvalidRequiredArgumentException::class); + + $params = new RcsParams('Text content', '491716992343'); + + $suggestions = []; + for ($i = 0; $i < 12; ++$i) { + $suggestions[] = new RcsSuggestionParams( + RcsSuggestionType::REPLY, + 'Answer ' . $i, + 'answer_' . $i + ); + } + + $params->setSuggestions(...$suggestions); + + (new RcsValidator($params))->validate(); + } + + public function testValidateFailsWhenOpenUrlSuggestionHasNoUrl(): void + { + $this->expectException(InvalidRequiredArgumentException::class); + + $params = (new RcsParams('Text content', '491716992343')) + ->setSuggestions( + new RcsSuggestionParams(RcsSuggestionType::OPEN_URL, 'Open', 'open_site') + ); + + (new RcsValidator($params))->validate(); + } + + public function testToArraySupportsStructuredFallbackWithSender(): void + { + $params = (new RcsParams('RCS content', '491716992343')) + ->setFallback( + (new RcsFallbackParams(RcsFallbackType::SMS)) + ->setText('SMS fallback text') + ->setFrom('MyBrand') + ); + + $payload = $params->toArray(); + + $this->assertSame('sms', $payload['fallback']['type']); + $this->assertSame('SMS fallback text', $payload['fallback']['text']); + $this->assertSame('MyBrand', $payload['fallback']['from']); + } + + public function testValidateFailsWhenSmsFallbackObjectHasNoText(): void + { + $this->expectException(InvalidRequiredArgumentException::class); + + $params = (new RcsParams('RCS content', '491716992343')) + ->setFallback( + (new RcsFallbackParams(RcsFallbackType::SMS)) + ->setFrom('MyBrand') + ); + + (new RcsValidator($params))->validate(); + } +}