diff --git a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php index b3ec5c19..e88d8a57 100644 --- a/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php +++ b/src/Utopia/Messaging/Adapter/SMS/GEOSMS.php @@ -4,6 +4,7 @@ use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Adapter\SMS\GEOSMS\CallingCode; +use Utopia\Messaging\Adapter\SMS\Msg91\MetadataParameter; use Utopia\Messaging\Messages\SMS; use Utopia\Telemetry\Adapter as Telemetry; use Utopia\Telemetry\Adapter\None as NoTelemetry; @@ -82,28 +83,59 @@ protected function process(SMS $message): array { $results = []; $recipients = $message->getTo(); + $batches = []; do { [$nextRecipients, $nextAdapter] = $this->getNextRecipientsAndAdapter($recipients); + $batches[] = [ + 'recipients' => $nextRecipients, + 'adapter' => $nextAdapter, + ]; + + $recipients = \array_diff($recipients, $nextRecipients); + } while (count($recipients) > 0); + + foreach ($batches as $index => $batch) { + /** @var array|null $metadata */ + $metadata = $message->getMetadata(); + if (\count($batches) > 1 && $metadata !== null) { + foreach ([MetadataParameter::CRQID, MetadataParameter::UUID] as $parameter) { + $key = $parameter->value; + + if (!\array_key_exists($key, $metadata)) { + continue; + } + + if (!\is_string($metadata[$key])) { + throw new \InvalidArgumentException("Msg91 {$key} metadata must be a string."); + } + + if (\strlen($metadata[$key]) > 80 || !\preg_match('/^[A-Za-z0-9_.-]+$/', $metadata[$key])) { + throw new \InvalidArgumentException("Msg91 {$key} metadata must be 80 characters or less and contain only alphanumeric characters, underscores, dots, or hyphens."); + } + + $suffix = '-'.($index + 1); + $metadata[$key] = \substr($metadata[$key], 0, 80 - \strlen($suffix)).$suffix; + } + } try { - $results[$nextAdapter->getName()] = $nextAdapter->send( + $results[$batch['adapter']->getName()] = $batch['adapter']->send( (new SMS( - to: $nextRecipients, + to: $batch['recipients'], content: $message->getContent(), from: $message->getFrom(), - attachments: $message->getAttachments() + attachments: $message->getAttachments(), + metadata: $metadata ))->setOrigin($message->getOrigin()) ); } catch (\Exception $e) { - $results[$nextAdapter->getName()] = [ + $results[$batch['adapter']->getName()] = [ 'type' => 'error', 'message' => $e->getMessage(), ]; } - - $recipients = \array_diff($recipients, $nextRecipients); - } while (count($recipients) > 0); + } return $results; } diff --git a/src/Utopia/Messaging/Adapter/SMS/Msg91.php b/src/Utopia/Messaging/Adapter/SMS/Msg91.php index 8c77907c..660ccfe4 100644 --- a/src/Utopia/Messaging/Adapter/SMS/Msg91.php +++ b/src/Utopia/Messaging/Adapter/SMS/Msg91.php @@ -2,6 +2,7 @@ namespace Utopia\Messaging\Adapter\SMS; +use Utopia\Messaging\Adapter\SMS\Msg91\MetadataParameter; use Utopia\Messaging\Adapter\SMS as SMSAdapter; use Utopia\Messaging\Messages\SMS as SMSMessage; use Utopia\Messaging\Response; @@ -42,6 +43,27 @@ public function getMaxMessagesPerRequest(): int */ protected function process(SMSMessage $message): array { + $metadata = $message->getMetadata() ?? []; + $metadata = \array_intersect_key($metadata, \array_flip(\array_column(MetadataParameter::cases(), 'value'))); + + foreach ($metadata as $key => $value) { + if (!\is_string($value)) { + throw new \InvalidArgumentException("Msg91 {$key} metadata must be a string."); + } + } + + foreach ([MetadataParameter::CRQID, MetadataParameter::UUID] as $parameter) { + $key = $parameter->value; + + if (!\array_key_exists($key, $metadata)) { + continue; + } + + if (\strlen($metadata[$key]) > 80 || !\preg_match('/^[A-Za-z0-9_.-]+$/', $metadata[$key])) { + throw new \InvalidArgumentException("Msg91 {$key} metadata must be 80 characters or less and contain only alphanumeric characters, underscores, dots, or hyphens."); + } + } + $recipients = []; foreach ($message->getTo() as $recipient) { $recipients[] = [ @@ -51,6 +73,16 @@ protected function process(SMSMessage $message): array ]; } + $body = [ + 'sender' => $this->senderId, + 'template_id' => $this->templateId, + 'recipients' => $recipients, + ]; + + foreach ($metadata as $key => $value) { + $body[$key] = $value; + } + $response = new Response($this->getType()); $result = $this->request( method: 'POST', @@ -59,11 +91,7 @@ protected function process(SMSMessage $message): array 'Content-Type: application/json', 'Authkey: '. $this->authKey, ], - body: [ - 'sender' => $this->senderId, - 'template_id' => $this->templateId, - 'recipients' => $recipients, - ], + body: $body, ); if ($result['statusCode'] === 200) { diff --git a/src/Utopia/Messaging/Adapter/SMS/Msg91/MetadataParameter.php b/src/Utopia/Messaging/Adapter/SMS/Msg91/MetadataParameter.php new file mode 100644 index 00000000..7d897baf --- /dev/null +++ b/src/Utopia/Messaging/Adapter/SMS/Msg91/MetadataParameter.php @@ -0,0 +1,21 @@ + $to * @param array|null $attachments + * @param array|null $metadata */ public function __construct( private array $to, private string $content, private ?string $from = null, private ?array $attachments = null, + private ?array $metadata = null, ) { } @@ -46,6 +48,24 @@ public function getAttachments(): ?array return $this->attachments; } + /** + * @return array|null + */ + public function getMetadata(): ?array + { + return $this->metadata; + } + + /** + * @param array|null $metadata + */ + public function setMetadata(?array $metadata): self + { + $this->metadata = $metadata; + + return $this; + } + public function setOrigin(?string $origin): self { $this->origin = $origin; diff --git a/tests/Messaging/Adapter/SMS/GEOSMSTest.php b/tests/Messaging/Adapter/SMS/GEOSMSTest.php index 82633336..893d87d6 100644 --- a/tests/Messaging/Adapter/SMS/GEOSMSTest.php +++ b/tests/Messaging/Adapter/SMS/GEOSMSTest.php @@ -111,4 +111,116 @@ public function testSendSMSUsingGroupedLocalAdapter(): void $this->assertEquals(1, count($result)); $this->assertEquals('success', $result['local']['results'][0]['status']); } + + public function testSendSMSHandlesMetadata(): void + { + $metadata = [ + 'clientId' => 'client-123', + 'CRQID' => 'request_123', + 'UUID' => 'uuid.123', + ]; + + $defaultAdapterMock = $this->createMock(SMSAdapter::class); + $defaultAdapterMock->method('getName')->willReturn('default'); + $defaultAdapterMock + ->expects($this->once()) + ->method('send') + ->with($this->callback(function (SMS $message) use ($metadata): bool { + return $message->getMetadata() === $metadata; + })) + ->willReturn(['results' => [['status' => 'success']]]); + + $adapter = new GEOSMS($defaultAdapterMock); + + $message = new SMS( + to: ['+11234567890'], + content: 'Test Content', + metadata: $metadata + ); + + $result = $adapter->send($message); + + $this->assertEquals(1, count($result)); + $this->assertEquals('success', $result['default']['results'][0]['status']); + + $defaultMetadata = [ + 'clientId' => 'client-123', + 'CRQID' => 'request_123-2', + 'UUID' => 'uuid.123-2', + ]; + + $localMetadata = [ + 'clientId' => 'client-123', + 'CRQID' => 'request_123-1', + 'UUID' => 'uuid.123-1', + ]; + + $defaultAdapterMock = $this->createMock(SMSAdapter::class); + $defaultAdapterMock->method('getName')->willReturn('default'); + $defaultAdapterMock + ->expects($this->once()) + ->method('send') + ->with($this->callback(function (SMS $message) use ($defaultMetadata): bool { + return $message->getMetadata() === $defaultMetadata; + })) + ->willReturn(['results' => [['status' => 'success']]]); + + $localAdapterMock = $this->createMock(SMSAdapter::class); + $localAdapterMock->method('getName')->willReturn('local'); + $localAdapterMock + ->expects($this->once()) + ->method('send') + ->with($this->callback(function (SMS $message) use ($localMetadata): bool { + return $message->getMetadata() === $localMetadata; + })) + ->willReturn(['results' => [['status' => 'success']]]); + + $adapter = new GEOSMS($defaultAdapterMock); + $adapter->setLocal(CallingCode::INDIA, $localAdapterMock); + + $message = new SMS( + to: ['+911234567890', '+11234567890'], + content: 'Test Content', + metadata: $metadata + ); + + $result = $adapter->send($message); + + $this->assertEquals(2, count($result)); + $this->assertEquals('success', $result['local']['results'][0]['status']); + $this->assertEquals('success', $result['default']['results'][0]['status']); + + /** @var array $invalidMetadata */ + $invalidMetadata = [ + 'CRQID' => [], + ]; + + $message = new SMS( + to: ['+911234567890', '+11234567890'], + content: 'Test Content', + metadata: $invalidMetadata + ); + + try { + $adapter->send($message); + $this->fail('Expected invalid GEOSMS metadata to throw.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Msg91 CRQID metadata must be a string', $e->getMessage()); + } + + $message = new SMS( + to: ['+911234567890', '+11234567890'], + content: 'Test Content', + metadata: [ + 'CRQID' => '', + ] + ); + + try { + $adapter->send($message); + $this->fail('Expected empty GEOSMS metadata to throw.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Msg91 CRQID metadata must be 80 characters or less', $e->getMessage()); + } + } } diff --git a/tests/Messaging/Adapter/SMS/Msg91Test.php b/tests/Messaging/Adapter/SMS/Msg91Test.php index 854bdeab..3dff601f 100644 --- a/tests/Messaging/Adapter/SMS/Msg91Test.php +++ b/tests/Messaging/Adapter/SMS/Msg91Test.php @@ -21,4 +21,109 @@ public function testSendSMS(): void $this->assertResponse($response); } + + public function testSendSMSHandlesMetadata(): void + { + $sender = new Msg91TestAdapter('sender', 'auth', 'template'); + + $message = new SMS( + to: ['+911234567890', '+911234567891'], + content: 'Test Content', + metadata: [ + 'clientId' => 'client-123', + 'CRQID' => 'request_123', + 'UUID' => 'uuid.123', + 'ignored' => 'value', + ], + ); + + $response = $sender->send($message); + + $this->assertEquals(2, $response['deliveredTo'], \var_export($response, true)); + $this->assertEquals('', $response['results'][0]['error'], \var_export($response, true)); + $this->assertEquals('success', $response['results'][0]['status'], \var_export($response, true)); + $this->assertEquals('', $response['results'][1]['error'], \var_export($response, true)); + $this->assertEquals('success', $response['results'][1]['status'], \var_export($response, true)); + $this->assertEquals('client-123', $sender->body['clientId']); + $this->assertEquals('request_123', $sender->body['CRQID']); + $this->assertEquals('uuid.123', $sender->body['UUID']); + $this->assertArrayNotHasKey('CRQID', $sender->body['recipients'][0]); + $this->assertArrayNotHasKey('UUID', $sender->body['recipients'][0]); + $this->assertArrayNotHasKey('CRQID', $sender->body['recipients'][1]); + $this->assertArrayNotHasKey('UUID', $sender->body['recipients'][1]); + $this->assertArrayNotHasKey('ignored', $sender->body); + + $sender = new Msg91TestAdapter('sender', 'auth', 'template'); + + $message = new SMS( + to: ['+911234567890'], + content: 'Test Content', + metadata: [ + 'CRQID' => 'invalid value', + ], + ); + + try { + $sender->send($message); + $this->fail('Expected invalid MSG91 metadata to throw.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Msg91 CRQID metadata must be 80 characters or less', $e->getMessage()); + } + + /** @var array $metadata */ + $metadata = [ + 'CRQID' => null, + ]; + + $message = new SMS( + to: ['+911234567890'], + content: 'Test Content', + metadata: $metadata, + ); + + try { + $sender->send($message); + $this->fail('Expected null MSG91 metadata to throw.'); + } catch (\InvalidArgumentException $e) { + $this->assertStringContainsString('Msg91 CRQID metadata must be a string', $e->getMessage()); + } + } +} + +class Msg91TestAdapter extends Msg91 +{ + /** + * @var array + */ + public array $body = []; + + /** + * @param array $headers + * @param array|null $body + * @return array{ + * url: string, + * statusCode: int, + * response: array|string|null, + * headers: array, + * error: string|null + * } + */ + protected function request( + string $method, + string $url, + array $headers = [], + ?array $body = null, + int $timeout = 30, + int $connectTimeout = 10 + ): array { + $this->body = $body ?? []; + + return [ + 'url' => $url, + 'statusCode' => 200, + 'response' => [], + 'headers' => [], + 'error' => null, + ]; + } }