Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 39 additions & 7 deletions src/Utopia/Messaging/Adapter/SMS/GEOSMS.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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<string, mixed>|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.");
Comment on lines +101 to +114

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Scope MSG91 validation

GEOSMS is a generic router, but this branch applies MSG91-only CRQID and UUID rules whenever a send is split into multiple batches. A GEOSMS setup that routes only to non-MSG91 adapters can now reject metadata such as CRQID => "order 123" before any child adapter runs, even though those adapters would otherwise ignore or preserve the metadata. Because this runs before the per-adapter try block, the same invalid value also aborts the entire send() instead of being reported under the affected adapter while later batches continue. Please leave provider-specific validation to the MSG91 adapter, or only apply it inside the child-send path for batches that are actually sent through MSG91.

}

$suffix = '-'.($index + 1);
$metadata[$key] = \substr($metadata[$key], 0, 80 - \strlen($suffix)).$suffix;
Comment thread
greptile-apps[bot] marked this conversation as resolved.
}
}

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;
}
Expand Down
38 changes: 33 additions & 5 deletions src/Utopia/Messaging/Adapter/SMS/Msg91.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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[] = [
Expand All @@ -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;
}
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
greptile-apps[bot] marked this conversation as resolved.

$response = new Response($this->getType());
$result = $this->request(
method: 'POST',
Expand All @@ -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) {
Expand Down
21 changes: 21 additions & 0 deletions src/Utopia/Messaging/Adapter/SMS/Msg91/MetadataParameter.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
<?php

namespace Utopia\Messaging\Adapter\SMS\Msg91;

enum MetadataParameter: string
{
/**
* Returned in Webhook v2 callbacks to correlate MSG91 responses with the original request.
*/
case CLIENT_ID = 'clientId';

/**
* Request-level tracking ID returned in delivery reports and webhook callbacks.
*/
case CRQID = 'CRQID';

/**
* Alternative request-level tracking ID returned in delivery reports and webhook callbacks.
*/
case UUID = 'UUID';
}
20 changes: 20 additions & 0 deletions src/Utopia/Messaging/Messages/SMS.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,14 @@ class SMS implements Message
/**
* @param array<string> $to
* @param array<string>|null $attachments
* @param array<string, string>|null $metadata
*/
public function __construct(
private array $to,
private string $content,
private ?string $from = null,
private ?array $attachments = null,
private ?array $metadata = null,
) {
}

Expand Down Expand Up @@ -46,6 +48,24 @@ public function getAttachments(): ?array
return $this->attachments;
}

/**
* @return array<string, string>|null
*/
public function getMetadata(): ?array
{
return $this->metadata;
}

/**
* @param array<string, string>|null $metadata
*/
public function setMetadata(?array $metadata): self
{
$this->metadata = $metadata;

return $this;
}

public function setOrigin(?string $origin): self
{
$this->origin = $origin;
Expand Down
112 changes: 112 additions & 0 deletions tests/Messaging/Adapter/SMS/GEOSMSTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, string> $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());
}
}
}
Loading
Loading