From ded0e1386cf1543ec0a9c20a555fcba52e72579c Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Mon, 14 Sep 2026 18:32:42 +0100 Subject: [PATCH 1/6] feat(messaging): deliver one-time codes over WhatsApp authentication templates Meta's Cloud API is the common denominator behind every WhatsApp OTP route (Twilio, Vonage and Infobip all still require the customer's own business account), so the adapter talks to the Graph API directly. It models WhatsApp as an SMS-type adapter because callers already send the bare code as the message content, which is the only thing an authentication template accepts. The code is validated to Meta's 15-character alphanumeric limit before any request leaves, and Graph API errors keep their numeric code so callers can tell throughput limits from configuration mistakes. upsertTemplate() lets a host provision the template in every language it needs. --- .../config/vocabularies/Utopia/accept.txt | 1 + packages/messaging/README.md | 25 ++ .../Utopia/Messaging/Adapter/SMS/WhatsApp.php | 297 +++++++++++++++ .../SMS/WhatsApp/MetadataParameter.php | 14 + .../Messaging/Adapter/SMS/WhatsAppTest.php | 345 ++++++++++++++++++ 5 files changed, 682 insertions(+) create mode 100644 packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php create mode 100644 packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php create mode 100644 packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php diff --git a/.vale/styles/config/vocabularies/Utopia/accept.txt b/.vale/styles/config/vocabularies/Utopia/accept.txt index 0e78b8319..aca7d977c 100644 --- a/.vale/styles/config/vocabularies/Utopia/accept.txt +++ b/.vale/styles/config/vocabularies/Utopia/accept.txt @@ -220,6 +220,7 @@ worktree(?:s)? webOS WebView Wget +WhatsApp YAML Yandex zstd diff --git a/packages/messaging/README.md b/packages/messaging/README.md index ac0deabfe..9bb36f91a 100644 --- a/packages/messaging/README.md +++ b/packages/messaging/README.md @@ -69,6 +69,30 @@ $messaging = new Telesign('YOUR_USERNAME', 'YOUR_PASSWORD'); $messaging->send($message); ``` +### One-time codes over WhatsApp + +The WhatsApp adapter sends codes through a Meta Cloud API authentication template. Meta fixes the template text, so the message content is the code itself: up to 15 letters and digits, no free text. Create the template once per language, then send with the same `SMS` message type. + +```php +upsertTemplate('YOUR_BUSINESS_ACCOUNT_ID', ['en_US', 'fr'], expirationMinutes: 10); + +$message = new SMS( + to: ['+12025550139'], + content: '482913' +); +$message->setMetadata([MetadataParameter::LANGUAGE->value => 'fr']); + +$messaging->send($message); +``` + ## Push ```php @@ -118,6 +142,7 @@ $messaging->send($message); - [x] [Seven](https://www.seven.io/) - [ ] [SmsGlobal](https://www.smsglobal.com/) - [x] [Inforu](https://www.inforu.co.il/) +- [x] [WhatsApp](https://developers.facebook.com/docs/whatsapp/cloud-api) (one-time codes through authentication templates) ### Push - [x] [FCM](https://firebase.google.com/docs/cloud-messaging) diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php new file mode 100644 index 000000000..4892a64a9 --- /dev/null +++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php @@ -0,0 +1,297 @@ + $languages Language codes to create the template in; empty creates every supported language. + * @param int|null $expirationMinutes Validity stated in the footer, between 1 and 90; null omits the footer. + * @param bool $securityRecommendation Whether the body tells the recipient not to share the code. + * @param int|null $timeToLive Seconds after which an undelivered message is dropped, between 60 and 600, or -1 for 24 hours. + * @return array Decoded Graph API response. + * + * @throws \InvalidArgumentException If the footer validity is out of range. + * @throws \RuntimeException If the Graph API rejects the request. + */ + public function upsertTemplate( + string $businessAccountId, + array $languages = [], + ?int $expirationMinutes = null, + bool $securityRecommendation = true, + ?int $timeToLive = null, + ): array { + if ($expirationMinutes !== null && ($expirationMinutes < 1 || $expirationMinutes > self::EXPIRATION_MAX_MINUTES)) { + throw new \InvalidArgumentException('WhatsApp code expiration must be between 1 and ' . self::EXPIRATION_MAX_MINUTES . ' minutes.'); + } + + $components = [ + [ + 'type' => 'body', + 'add_security_recommendation' => $securityRecommendation, + ], + ]; + + if ($expirationMinutes !== null) { + $components[] = [ + 'type' => 'footer', + 'code_expiration_minutes' => $expirationMinutes, + ]; + } + + $components[] = [ + 'type' => 'buttons', + 'buttons' => [ + [ + 'type' => self::BUTTON_TYPE, + 'otp_type' => self::BUTTON_OTP_TYPE, + ], + ], + ]; + + $body = [ + 'name' => $this->template, + 'category' => self::CATEGORY, + 'components' => $components, + ]; + + if ($languages !== []) { + $body['languages'] = array_values($languages); + } + + if ($timeToLive !== null) { + $body['message_send_ttl_seconds'] = $timeToLive; + } + + $result = $this->request( + method: 'POST', + url: $this->url($businessAccountId, 'upsert_message_templates'), + headers: $this->headers(), + body: $body, + ); + + if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) { + throw new \RuntimeException($this->error($result)); + } + + return \is_array($result['response']) ? $result['response'] : []; + } + + /** + * {@inheritdoc} + */ + protected function process(SMSMessage $message): array + { + $code = $message->getContent(); + + if (preg_match(self::CODE_PATTERN, $code) !== 1) { + throw new \InvalidArgumentException('WhatsApp authentication templates only accept a code of up to ' . self::CODE_MAX_LENGTH . ' letters and digits as the message content.'); + } + + $language = $message->getMetadata()[MetadataParameter::LANGUAGE->value] ?? $this->language; + + if (!\is_string($language) || $language === '') { + throw new \InvalidArgumentException('WhatsApp language metadata must be a non-empty string.'); + } + + $to = $message->getTo()[0]; + + $result = $this->request( + method: 'POST', + url: $this->url($this->phoneNumberId, 'messages'), + headers: $this->headers(), + body: [ + 'messaging_product' => self::PRODUCT, + 'recipient_type' => 'individual', + 'to' => $this->normalize($to), + 'type' => 'template', + 'template' => [ + 'name' => $this->template, + 'language' => [ + 'code' => $language, + ], + 'components' => [ + [ + 'type' => 'body', + 'parameters' => [ + [ + 'type' => 'text', + 'text' => $code, + ], + ], + ], + [ + 'type' => 'button', + 'sub_type' => self::BUTTON_SUB_TYPE, + 'index' => '0', + 'parameters' => [ + [ + 'type' => 'text', + 'text' => $code, + ], + ], + ], + ], + ], + ], + ); + + $response = new Response($this->getType()); + + if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + $response->setDeliveredTo(1); + $response->addResult($to); + } else { + $response->addResult($to, $this->error($result)); + } + + return $response->toArray(); + } + + /** + * The Cloud API wants the number as digits only, country code first, without a plus sign. + */ + private function normalize(string $number): string + { + return preg_replace('/\D+/', '', $number) ?? ''; + } + + private function url(string $id, string $edge): string + { + return self::ENDPOINT . '/' . $this->version . '/' . $id . '/' . $edge; + } + + /** + * @return array + */ + private function headers(): array + { + return [ + 'Content-Type: application/json', + 'Authorization: Bearer ' . $this->accessToken, + ]; + } + + /** + * Turn a Graph API error into one line keeping the numeric code, which is + * what distinguishes retryable throughput limits from configuration mistakes. + * + * @param array{statusCode: int, response: array|string|null, error: string} $result + */ + private function error(array $result): string + { + if ($result['statusCode'] === 0) { + return $result['error'] !== '' ? $result['error'] : 'Unknown error'; + } + + $error = \is_array($result['response']) ? ($result['response']['error'] ?? null) : null; + + if (!\is_array($error)) { + return 'Unknown error'; + } + + $parts = []; + + if (isset($error['code'])) { + $parts[] = 'Error ' . $error['code']; + } + + if (isset($error['message']) && \is_string($error['message'])) { + $parts[] = $error['message']; + } + + $details = $error['error_data']['details'] ?? null; + + if (\is_string($details) && $details !== '' && !\in_array($details, $parts, true)) { + $parts[] = $details; + } + + return $parts === [] ? 'Unknown error' : implode(': ', $parts); + } +} diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php new file mode 100644 index 000000000..77dc1d305 --- /dev/null +++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php @@ -0,0 +1,14 @@ + 'whatsapp', + 'contacts' => [['input' => '14155551234', 'wa_id' => '14155551234']], + 'messages' => [['id' => 'wamid.HBgL', 'message_status' => 'accepted']], + ]); + $adapter = $this->adapter($client); + + $response = $adapter->send(new SMS(['+1 (415) 555-1234'], '482913')); + + $this->assertResponse($response); + $this->assertSame('+1 (415) 555-1234', $response['results'][0]['recipient']); + + $request = $client->request; + $this->assertInstanceOf(RequestInterface::class, $request); + $this->assertSame('POST', $request->getMethod()); + $this->assertSame('https://graph.facebook.com/v26.0/' . self::PHONE_NUMBER_ID . '/messages', (string) $request->getUri()); + $this->assertSame('Bearer ' . self::TOKEN, $request->getHeaderLine('Authorization')); + $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); + $this->assertSame('Appwrite WhatsApp Message Sender', $request->getHeaderLine('User-Agent')); + + $this->assertSame([ + 'messaging_product' => 'whatsapp', + 'recipient_type' => 'individual', + 'to' => '14155551234', + 'type' => 'template', + 'template' => [ + 'name' => self::TEMPLATE, + 'language' => ['code' => 'en_US'], + 'components' => [ + [ + 'type' => 'body', + 'parameters' => [['type' => 'text', 'text' => '482913']], + ], + [ + 'type' => 'button', + 'sub_type' => 'url', + 'index' => '0', + 'parameters' => [['type' => 'text', 'text' => '482913']], + ], + ], + ], + ], $client->body()); + } + + public function testUsesConstructorLanguageAndVersion(): void + { + $client = new RecordingClient(200, ['messages' => [['id' => 'wamid.1']]]); + $adapter = new WhatsApp(self::TOKEN, self::PHONE_NUMBER_ID, self::TEMPLATE, 'pt_BR', 'v23.0', $this->factory($client)); + + $adapter->send(new SMS(['+5511987654321'], '123456')); + + $this->assertInstanceOf(RequestInterface::class, $client->request); + $this->assertStringStartsWith('https://graph.facebook.com/v23.0/', (string) $client->request->getUri()); + $this->assertSame('pt_BR', $client->body()['template']['language']['code']); + } + + public function testMetadataOverridesLanguage(): void + { + $client = new RecordingClient(200, ['messages' => [['id' => 'wamid.1']]]); + $adapter = $this->adapter($client); + + $message = new SMS(['+33612345678'], '123456'); + $message->setMetadata([MetadataParameter::LANGUAGE->value => 'fr']); + $adapter->send($message); + + $this->assertSame('fr', $client->body()['template']['language']['code']); + } + + public function testRejectsEmptyLanguageMetadata(): void + { + $adapter = $this->adapter(new RecordingClient(200, [])); + + $message = new SMS(['+33612345678'], '123456'); + $message->setMetadata([MetadataParameter::LANGUAGE->value => '']); + + $this->expectException(\InvalidArgumentException::class); + $adapter->send($message); + } + + /** + * @return iterable + */ + public static function invalidCodes(): iterable + { + yield 'empty' => ['']; + yield 'too long' => ['1234567890123456']; + yield 'free text' => ['Your code is 123456']; + yield 'newline' => ["123\n456"]; + yield 'symbols' => ['12-34']; + } + + #[DataProvider('invalidCodes')] + public function testRejectsContentThatIsNotACode(string $content): void + { + $client = new RecordingClient(200, []); + $adapter = $this->adapter($client); + + try { + $adapter->send(new SMS(['+14155551234'], $content)); + $this->fail('Expected an InvalidArgumentException.'); + } catch (\InvalidArgumentException $exception) { + $this->assertStringContainsString('15 letters and digits', $exception->getMessage()); + } + + $this->assertNotInstanceOf(RequestInterface::class, $client->request, 'No request must be sent for an invalid code.'); + } + + public function testAcceptsAlphanumericCodeAtMaximumLength(): void + { + $client = new RecordingClient(200, ['messages' => [['id' => 'wamid.1']]]); + $adapter = $this->adapter($client); + + $response = $adapter->send(new SMS(['+14155551234'], 'ABC123XYZ789ABC')); + + $this->assertResponse($response); + } + + public function testReportsGraphApiErrorWithCode(): void + { + $client = new RecordingClient(400, [ + 'error' => [ + 'message' => '(#132001) Template name does not exist in the translation', + 'type' => 'OAuthException', + 'code' => 132001, + 'error_data' => [ + 'messaging_product' => 'whatsapp', + 'details' => 'template name (appwrite_otp) does not exist in fr', + ], + 'fbtrace_id' => 'AbC', + ], + ]); + $adapter = $this->adapter($client); + + $response = $adapter->send(new SMS(['+33612345678'], '123456')); + + $this->assertSame(0, $response['deliveredTo']); + $this->assertSame('failure', $response['results'][0]['status']); + $this->assertSame( + 'Error 132001: (#132001) Template name does not exist in the translation: template name (appwrite_otp) does not exist in fr', + $response['results'][0]['error'], + ); + } + + public function testReportsUndeliverableRecipient(): void + { + $client = new RecordingClient(400, [ + 'error' => [ + 'message' => 'Message Undeliverable', + 'code' => 131026, + 'error_data' => ['details' => 'Message Undeliverable'], + ], + ]); + $adapter = $this->adapter($client); + + $response = $adapter->send(new SMS(['+14155551234'], '123456')); + + $this->assertSame('Error 131026: Message Undeliverable', $response['results'][0]['error']); + } + + public function testReportsInvalidToken(): void + { + $client = new RecordingClient(401, [ + 'error' => ['message' => 'Invalid OAuth access token', 'code' => 190], + ]); + $adapter = $this->adapter($client); + + $response = $adapter->send(new SMS(['+14155551234'], '123456')); + + $this->assertSame('Error 190: Invalid OAuth access token', $response['results'][0]['error']); + } + + public function testReportsUnexpectedBody(): void + { + $client = new RecordingClient(502, 'Bad Gateway'); + $adapter = $this->adapter($client); + + $response = $adapter->send(new SMS(['+14155551234'], '123456')); + + $this->assertSame('Unknown error', $response['results'][0]['error']); + } + + public function testReportsTransportFailure(): void + { + $client = new RecordingClient(0, null, new TransportException('Could not resolve host')); + $adapter = $this->adapter($client); + + $response = $adapter->send(new SMS(['+14155551234'], '123456')); + + $this->assertSame(0, $response['deliveredTo']); + $this->assertSame('Could not resolve host', $response['results'][0]['error']); + } + + public function testRefusesMoreThanOneRecipient(): void + { + $adapter = $this->adapter(new RecordingClient(200, [])); + + $this->expectExceptionMessage('WhatsApp can only send 1 messages per request.'); + $adapter->send(new SMS(['+1', '+2'], '123456')); + } + + public function testUpsertsTemplateInEveryLanguage(): void + { + $client = new RecordingClient(200, [ + 'data' => [ + ['id' => '1', 'status' => 'APPROVED', 'language' => 'en_US'], + ['id' => '2', 'status' => 'APPROVED', 'language' => 'fr'], + ], + ]); + $adapter = $this->adapter($client); + + $result = $adapter->upsertTemplate('102290129340398', ['en_US', 'fr'], 10, true, 600); + + $this->assertCount(2, $result['data']); + $this->assertInstanceOf(RequestInterface::class, $client->request); + $this->assertSame('https://graph.facebook.com/v26.0/102290129340398/upsert_message_templates', (string) $client->request->getUri()); + $this->assertSame('Bearer ' . self::TOKEN, $client->request->getHeaderLine('Authorization')); + $this->assertSame([ + 'name' => self::TEMPLATE, + 'category' => 'authentication', + 'components' => [ + ['type' => 'body', 'add_security_recommendation' => true], + ['type' => 'footer', 'code_expiration_minutes' => 10], + ['type' => 'buttons', 'buttons' => [['type' => 'otp', 'otp_type' => 'copy_code']]], + ], + 'languages' => ['en_US', 'fr'], + 'message_send_ttl_seconds' => 600, + ], $client->body()); + } + + public function testUpsertsTemplateWithoutOptionalParts(): void + { + $client = new RecordingClient(200, ['data' => []]); + $adapter = $this->adapter($client); + + $adapter->upsertTemplate('102290129340398', securityRecommendation: false); + + $this->assertSame([ + 'name' => self::TEMPLATE, + 'category' => 'authentication', + 'components' => [ + ['type' => 'body', 'add_security_recommendation' => false], + ['type' => 'buttons', 'buttons' => [['type' => 'otp', 'otp_type' => 'copy_code']]], + ], + ], $client->body()); + } + + public function testUpsertRejectsExpirationOutOfRange(): void + { + $adapter = $this->adapter(new RecordingClient(200, [])); + + $this->expectException(\InvalidArgumentException::class); + $adapter->upsertTemplate('102290129340398', ['en_US'], 91); + } + + public function testUpsertThrowsOnGraphApiError(): void + { + $client = new RecordingClient(400, [ + 'error' => ['message' => 'Unsupported post request', 'code' => 100], + ]); + $adapter = $this->adapter($client); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Error 100: Unsupported post request'); + $adapter->upsertTemplate('bad-id', ['en_US']); + } + + private function adapter(RecordingClient $client): WhatsApp + { + return new WhatsApp(self::TOKEN, self::PHONE_NUMBER_ID, self::TEMPLATE, clientFactory: $this->factory($client)); + } + + private function factory(RecordingClient $client): \Closure + { + return static fn(): ClientInterface => $client; + } +} + +final class RecordingClient implements ClientInterface +{ + public ?RequestInterface $request = null; + + /** + * @param array|string|null $response + */ + public function __construct( + private readonly int $statusCode, + private readonly array|string|null $response, + private readonly ?ClientExceptionInterface $exception = null, + ) {} + + public function sendRequest(RequestInterface $request): ResponseInterface + { + $this->request = $request; + + if ($this->exception instanceof ClientExceptionInterface) { + throw $this->exception; + } + + $body = \is_array($this->response) ? json_encode($this->response, JSON_THROW_ON_ERROR) : (string) $this->response; + + return new Response($this->statusCode, '', new StreamFactory()->createStream($body)); + } + + /** + * @return array + */ + public function body(): array + { + if (!$this->request instanceof RequestInterface) { + throw new \LogicException('No request recorded.'); + } + + return json_decode((string) $this->request->getBody(), true, flags: JSON_THROW_ON_ERROR); + } +} + +final class TransportException extends \RuntimeException implements ClientExceptionInterface {} From 3fa02e3b1d95fcf0323a5c7469d63e04d8fbe1ad Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 15 Sep 2026 08:07:06 +0100 Subject: [PATCH 2/6] fix(messaging): reject empty recipients and out-of-range TTL before calling Meta An SMS with no recipient reached the [0] access and died with a TypeError rather than a clear argument error, and a time to live outside Meta's documented 60 to 600 second window (or -1) was sent anyway only to fail remotely. Both are now validated locally. Tests assert what a caller can observe (code in both parameters, digits-only recipient, chosen language, bearer credential) instead of pinning the whole request body and version. --- .../Utopia/Messaging/Adapter/SMS/WhatsApp.php | 24 ++- .../Messaging/Adapter/SMS/WhatsAppTest.php | 165 +++++++++++++----- 2 files changed, 140 insertions(+), 49 deletions(-) diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php index 4892a64a9..1b75fe858 100644 --- a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php +++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php @@ -41,6 +41,18 @@ class WhatsApp extends SMSAdapter */ public const int EXPIRATION_MAX_MINUTES = 90; + /** + * Shortest and longest time an undelivered message may wait, in seconds. + */ + public const int TIME_TO_LIVE_MIN_SECONDS = 60; + + public const int TIME_TO_LIVE_MAX_SECONDS = 600; + + /** + * Sentinel asking Meta to keep an undelivered message for a full day. + */ + public const int TIME_TO_LIVE_DAY = -1; + private const string ENDPOINT = 'https://graph.facebook.com'; private const string PRODUCT = 'whatsapp'; @@ -97,7 +109,7 @@ public function getMaxMessagesPerRequest(): int * @param int|null $timeToLive Seconds after which an undelivered message is dropped, between 60 and 600, or -1 for 24 hours. * @return array Decoded Graph API response. * - * @throws \InvalidArgumentException If the footer validity is out of range. + * @throws \InvalidArgumentException If the footer validity or time to live is out of range. * @throws \RuntimeException If the Graph API rejects the request. */ public function upsertTemplate( @@ -111,6 +123,10 @@ public function upsertTemplate( throw new \InvalidArgumentException('WhatsApp code expiration must be between 1 and ' . self::EXPIRATION_MAX_MINUTES . ' minutes.'); } + if ($timeToLive !== null && $timeToLive !== self::TIME_TO_LIVE_DAY && ($timeToLive < self::TIME_TO_LIVE_MIN_SECONDS || $timeToLive > self::TIME_TO_LIVE_MAX_SECONDS)) { + throw new \InvalidArgumentException('WhatsApp time to live must be between ' . self::TIME_TO_LIVE_MIN_SECONDS . ' and ' . self::TIME_TO_LIVE_MAX_SECONDS . ' seconds, or ' . self::TIME_TO_LIVE_DAY . ' for 24 hours.'); + } + $components = [ [ 'type' => 'body', @@ -180,7 +196,11 @@ protected function process(SMSMessage $message): array throw new \InvalidArgumentException('WhatsApp language metadata must be a non-empty string.'); } - $to = $message->getTo()[0]; + $to = $message->getTo()[0] ?? null; + + if (!\is_string($to) || $to === '') { + throw new \InvalidArgumentException('WhatsApp requires exactly one recipient phone number.'); + } $result = $this->request( method: 'POST', diff --git a/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php b/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php index a0156a863..accf3293c 100644 --- a/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php +++ b/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php @@ -24,7 +24,7 @@ final class WhatsAppTest extends Base private const string TEMPLATE = 'appwrite_otp'; - public function testSendsCodeInBodyAndButton(): void + public function testDeliversCodeThroughTemplate(): void { $client = new RecordingClient(200, [ 'messaging_product' => 'whatsapp', @@ -41,33 +41,14 @@ public function testSendsCodeInBodyAndButton(): void $request = $client->request; $this->assertInstanceOf(RequestInterface::class, $request); $this->assertSame('POST', $request->getMethod()); - $this->assertSame('https://graph.facebook.com/v26.0/' . self::PHONE_NUMBER_ID . '/messages', (string) $request->getUri()); + $this->assertStringContainsString('/' . self::PHONE_NUMBER_ID . '/messages', $request->getUri()->getPath()); $this->assertSame('Bearer ' . self::TOKEN, $request->getHeaderLine('Authorization')); - $this->assertSame('application/json', $request->getHeaderLine('Content-Type')); - $this->assertSame('Appwrite WhatsApp Message Sender', $request->getHeaderLine('User-Agent')); - $this->assertSame([ - 'messaging_product' => 'whatsapp', - 'recipient_type' => 'individual', - 'to' => '14155551234', - 'type' => 'template', - 'template' => [ - 'name' => self::TEMPLATE, - 'language' => ['code' => 'en_US'], - 'components' => [ - [ - 'type' => 'body', - 'parameters' => [['type' => 'text', 'text' => '482913']], - ], - [ - 'type' => 'button', - 'sub_type' => 'url', - 'index' => '0', - 'parameters' => [['type' => 'text', 'text' => '482913']], - ], - ], - ], - ], $client->body()); + $body = $client->body(); + $this->assertSame('14155551234', $body['to'], 'Recipient must reach Meta as digits only.'); + $this->assertSame(self::TEMPLATE, $body['template']['name']); + $this->assertSame('en_US', $body['template']['language']['code']); + $this->assertSame(['482913', '482913'], $this->codes($body), 'The code must fill both the body and the button.'); } public function testUsesConstructorLanguageAndVersion(): void @@ -78,7 +59,7 @@ public function testUsesConstructorLanguageAndVersion(): void $adapter->send(new SMS(['+5511987654321'], '123456')); $this->assertInstanceOf(RequestInterface::class, $client->request); - $this->assertStringStartsWith('https://graph.facebook.com/v23.0/', (string) $client->request->getUri()); + $this->assertStringStartsWith('/v23.0/', $client->request->getUri()->getPath()); $this->assertSame('pt_BR', $client->body()['template']['language']['code']); } @@ -226,6 +207,21 @@ public function testRefusesMoreThanOneRecipient(): void $adapter->send(new SMS(['+1', '+2'], '123456')); } + public function testRefusesMissingRecipient(): void + { + $client = new RecordingClient(200, []); + $adapter = $this->adapter($client); + + try { + $adapter->send(new SMS([], '123456')); + $this->fail('Expected an InvalidArgumentException.'); + } catch (\InvalidArgumentException $exception) { + $this->assertStringContainsString('exactly one recipient', $exception->getMessage()); + } + + $this->assertNotInstanceOf(RequestInterface::class, $client->request, 'No request must be sent without a recipient.'); + } + public function testUpsertsTemplateInEveryLanguage(): void { $client = new RecordingClient(200, [ @@ -240,19 +236,16 @@ public function testUpsertsTemplateInEveryLanguage(): void $this->assertCount(2, $result['data']); $this->assertInstanceOf(RequestInterface::class, $client->request); - $this->assertSame('https://graph.facebook.com/v26.0/102290129340398/upsert_message_templates', (string) $client->request->getUri()); + $this->assertStringContainsString('/102290129340398/', $client->request->getUri()->getPath()); $this->assertSame('Bearer ' . self::TOKEN, $client->request->getHeaderLine('Authorization')); - $this->assertSame([ - 'name' => self::TEMPLATE, - 'category' => 'authentication', - 'components' => [ - ['type' => 'body', 'add_security_recommendation' => true], - ['type' => 'footer', 'code_expiration_minutes' => 10], - ['type' => 'buttons', 'buttons' => [['type' => 'otp', 'otp_type' => 'copy_code']]], - ], - 'languages' => ['en_US', 'fr'], - 'message_send_ttl_seconds' => 600, - ], $client->body()); + + $body = $client->body(); + $this->assertSame(self::TEMPLATE, $body['name']); + $this->assertSame('authentication', $body['category']); + $this->assertSame(['en_US', 'fr'], $body['languages']); + $this->assertSame(600, $body['message_send_ttl_seconds']); + $this->assertSame(10, $this->component($body, 'footer')['code_expiration_minutes']); + $this->assertTrue($this->component($body, 'body')['add_security_recommendation']); } public function testUpsertsTemplateWithoutOptionalParts(): void @@ -262,14 +255,59 @@ public function testUpsertsTemplateWithoutOptionalParts(): void $adapter->upsertTemplate('102290129340398', securityRecommendation: false); - $this->assertSame([ - 'name' => self::TEMPLATE, - 'category' => 'authentication', - 'components' => [ - ['type' => 'body', 'add_security_recommendation' => false], - ['type' => 'buttons', 'buttons' => [['type' => 'otp', 'otp_type' => 'copy_code']]], - ], - ], $client->body()); + $body = $client->body(); + $this->assertArrayNotHasKey('languages', $body, 'Omitting languages lets Meta create every supported one.'); + $this->assertArrayNotHasKey('message_send_ttl_seconds', $body); + $this->assertNull($this->component($body, 'footer')); + $this->assertFalse($this->component($body, 'body')['add_security_recommendation']); + } + + /** + * @return iterable + */ + public static function validTimeToLive(): iterable + { + yield 'day' => [WhatsApp::TIME_TO_LIVE_DAY]; + yield 'minimum' => [WhatsApp::TIME_TO_LIVE_MIN_SECONDS]; + yield 'maximum' => [WhatsApp::TIME_TO_LIVE_MAX_SECONDS]; + } + + #[DataProvider('validTimeToLive')] + public function testUpsertAcceptsTimeToLiveBoundaries(int $timeToLive): void + { + $client = new RecordingClient(200, ['data' => []]); + $adapter = $this->adapter($client); + + $adapter->upsertTemplate('102290129340398', ['en_US'], timeToLive: $timeToLive); + + $this->assertSame($timeToLive, $client->body()['message_send_ttl_seconds']); + } + + /** + * @return iterable + */ + public static function invalidTimeToLive(): iterable + { + yield 'zero' => [0]; + yield 'below minimum' => [WhatsApp::TIME_TO_LIVE_MIN_SECONDS - 1]; + yield 'above maximum' => [WhatsApp::TIME_TO_LIVE_MAX_SECONDS + 1]; + yield 'other negative' => [-2]; + } + + #[DataProvider('invalidTimeToLive')] + public function testUpsertRejectsTimeToLiveOutOfRange(int $timeToLive): void + { + $client = new RecordingClient(200, ['data' => []]); + $adapter = $this->adapter($client); + + try { + $adapter->upsertTemplate('102290129340398', ['en_US'], timeToLive: $timeToLive); + $this->fail('Expected an InvalidArgumentException.'); + } catch (\InvalidArgumentException $exception) { + $this->assertStringContainsString('time to live', $exception->getMessage()); + } + + $this->assertNotInstanceOf(RequestInterface::class, $client->request, 'No request must be sent for an invalid time to live.'); } public function testUpsertRejectsExpirationOutOfRange(): void @@ -292,6 +330,39 @@ public function testUpsertThrowsOnGraphApiError(): void $adapter->upsertTemplate('bad-id', ['en_US']); } + /** + * Every text parameter in the template, in component order. + * + * @param array $body + * @return array + */ + private function codes(array $body): array + { + $codes = []; + foreach ($body['template']['components'] as $component) { + foreach ($component['parameters'] as $parameter) { + $codes[] = $parameter['text']; + } + } + + return $codes; + } + + /** + * @param array $body + * @return array|null + */ + private function component(array $body, string $type): ?array + { + foreach ($body['components'] as $component) { + if ($component['type'] === $type) { + return $component; + } + } + + return null; + } + private function adapter(RecordingClient $client): WhatsApp { return new WhatsApp(self::TOKEN, self::PHONE_NUMBER_ID, self::TEMPLATE, clientFactory: $this->factory($client)); From edf10fa515d4d95c14043d9a525cf5a70a623e8e Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 15 Sep 2026 09:13:45 +0100 Subject: [PATCH 3/6] test(messaging): exercise the WhatsApp adapter against a Meta test number The unit tests prove the request shape with a fake client but never reach the Graph API. This suite runs in the e2e tier against Meta's free developer test number, which can message five allow-listed recipients, and is skipped unless the TESTS_WHATSAPP_* secrets are present so forks and PRs without credentials stay green. --- .github/workflows/tests.yml | 4 + packages/messaging/.env.dev | 4 + packages/messaging/phpunit.xml | 2 + .../Messaging/Adapter/SMS/WhatsAppE2ETest.php | 93 +++++++++++++++++++ 4 files changed, 103 insertions(+) create mode 100644 packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 0c6cd1393..743539f78 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -125,4 +125,8 @@ jobs: TESTS_GITHUB_INSTALLATION_ID: ${{ secrets.TESTS_GITHUB_INSTALLATION_ID }} TESTS_BITBUCKET_ACCESS_TOKEN: ${{ secrets.TESTS_BITBUCKET_ACCESS_TOKEN }} TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }} + TESTS_WHATSAPP_ACCESS_TOKEN: ${{ secrets.TESTS_WHATSAPP_ACCESS_TOKEN }} + TESTS_WHATSAPP_PHONE_NUMBER_ID: ${{ secrets.TESTS_WHATSAPP_PHONE_NUMBER_ID }} + TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID: ${{ secrets.TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID }} + TESTS_WHATSAPP_RECIPIENT: ${{ secrets.TESTS_WHATSAPP_RECIPIENT }} run: bin/monorepo test ${{ matrix.package }} ${{ matrix.linked && '--linked' || '' }} diff --git a/packages/messaging/.env.dev b/packages/messaging/.env.dev index 74dfc2465..3804579d1 100644 --- a/packages/messaging/.env.dev +++ b/packages/messaging/.env.dev @@ -36,3 +36,7 @@ FAST2SMS_MESSAGE_ID= FAST2SMS_TO= INFORU_API_TOKEN= INFORU_SENDER_ID= +TESTS_WHATSAPP_ACCESS_TOKEN= +TESTS_WHATSAPP_PHONE_NUMBER_ID= +TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID= +TESTS_WHATSAPP_RECIPIENT= diff --git a/packages/messaging/phpunit.xml b/packages/messaging/phpunit.xml index 848cf196d..710f5308d 100644 --- a/packages/messaging/phpunit.xml +++ b/packages/messaging/phpunit.xml @@ -12,11 +12,13 @@ tests/Messaging/Adapter/Email/EmailTest.php tests/Messaging/Adapter/Email/SMTPTest.php tests/Messaging/Adapter/SMS/SMSTest.php + tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php tests/Messaging/Adapter/Email/EmailTest.php tests/Messaging/Adapter/Email/SMTPTest.php tests/Messaging/Adapter/SMS/SMSTest.php + tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php diff --git a/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php b/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php new file mode 100644 index 000000000..47540d268 --- /dev/null +++ b/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php @@ -0,0 +1,93 @@ +businessAccountId = getenv('TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID') ?: ''; + $this->recipient = getenv('TESTS_WHATSAPP_RECIPIENT') ?: ''; + + if ($accessToken === '' || $phoneNumberId === '' || $this->businessAccountId === '' || $this->recipient === '') { + $this->markTestSkipped('Set TESTS_WHATSAPP_ACCESS_TOKEN, TESTS_WHATSAPP_PHONE_NUMBER_ID, TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID and TESTS_WHATSAPP_RECIPIENT to run against Meta.'); + } + + $this->adapter = new WhatsApp($accessToken, $phoneNumberId, self::TEMPLATE, self::LANGUAGE); + } + + public function testUpsertTemplateIsApprovedImmediately(): void + { + $result = $this->adapter->upsertTemplate($this->businessAccountId, [self::LANGUAGE], 10); + + $entries = $result['data'] ?? [$result]; + $this->assertNotEmpty($entries, 'Meta returned no template entry.'); + + $languages = []; + foreach ($entries as $entry) { + $this->assertSame('APPROVED', $entry['status'] ?? null, 'Authentication templates are approved without review.'); + $languages[] = $entry['language'] ?? self::LANGUAGE; + } + + $this->assertContains(self::LANGUAGE, $languages); + } + + #[Depends('testUpsertTemplateIsApprovedImmediately')] + public function testDeliversCodeToAllowListedRecipient(): void + { + $code = (string) random_int(100000, 999999); + + $response = $this->adapter->send(new SMS([$this->recipient], $code)); + + $this->assertResponse($response); + $this->assertSame($this->recipient, $response['results'][0]['recipient']); + } + + #[Depends('testUpsertTemplateIsApprovedImmediately')] + public function testReportsMissingTemplateLanguage(): void + { + $message = new SMS([$this->recipient], '123456'); + $message->setMetadata([MetadataParameter::LANGUAGE->value => 'zu']); + + $response = $this->adapter->send($message); + + $this->assertSame(0, $response['deliveredTo']); + $this->assertSame('failure', $response['results'][0]['status']); + $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'Meta reports a missing template translation as error 132001.'); + } + + public function testReportsRecipientOutsideAllowList(): void + { + $response = $this->adapter->send(new SMS(['+15550000001'], '123456')); + + $this->assertSame(0, $response['deliveredTo']); + $this->assertSame('failure', $response['results'][0]['status']); + $this->assertStringContainsString('131030', (string) $response['results'][0]['error'], 'A test number may only message allow-listed recipients.'); + } +} From dc1c2d43ba8449d9e9ea5767ae94421e0294f317 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 15 Sep 2026 09:56:21 +0100 Subject: [PATCH 4/6] test(messaging): keep only the end-to-end WhatsApp suite Meta's test account can run Meta's free test account ships sample templates only and refuses to create new ones, so no authentication template exists to deliver a code with. The suite now covers what that account does answer for real: an unknown template (132001), a recipient outside the allow list (131030) and a bad bearer token (190), each through the full request path. The in-process unit suite goes away so WhatsApp has one test that talks to the real API. --- .github/workflows/tests.yml | 1 - packages/messaging/.env.dev | 1 - packages/messaging/phpunit.xml | 4 +- .../Messaging/Adapter/SMS/WhatsAppE2ETest.php | 93 ---- .../Messaging/Adapter/SMS/WhatsAppTest.php | 409 ++---------------- 5 files changed, 35 insertions(+), 473 deletions(-) delete mode 100644 packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 743539f78..26c46969e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -127,6 +127,5 @@ jobs: TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }} TESTS_WHATSAPP_ACCESS_TOKEN: ${{ secrets.TESTS_WHATSAPP_ACCESS_TOKEN }} TESTS_WHATSAPP_PHONE_NUMBER_ID: ${{ secrets.TESTS_WHATSAPP_PHONE_NUMBER_ID }} - TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID: ${{ secrets.TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID }} TESTS_WHATSAPP_RECIPIENT: ${{ secrets.TESTS_WHATSAPP_RECIPIENT }} run: bin/monorepo test ${{ matrix.package }} ${{ matrix.linked && '--linked' || '' }} diff --git a/packages/messaging/.env.dev b/packages/messaging/.env.dev index 3804579d1..9ec46f9e3 100644 --- a/packages/messaging/.env.dev +++ b/packages/messaging/.env.dev @@ -38,5 +38,4 @@ INFORU_API_TOKEN= INFORU_SENDER_ID= TESTS_WHATSAPP_ACCESS_TOKEN= TESTS_WHATSAPP_PHONE_NUMBER_ID= -TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID= TESTS_WHATSAPP_RECIPIENT= diff --git a/packages/messaging/phpunit.xml b/packages/messaging/phpunit.xml index 710f5308d..dd543dc65 100644 --- a/packages/messaging/phpunit.xml +++ b/packages/messaging/phpunit.xml @@ -12,13 +12,13 @@ tests/Messaging/Adapter/Email/EmailTest.php tests/Messaging/Adapter/Email/SMTPTest.php tests/Messaging/Adapter/SMS/SMSTest.php - tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php + tests/Messaging/Adapter/SMS/WhatsAppTest.php tests/Messaging/Adapter/Email/EmailTest.php tests/Messaging/Adapter/Email/SMTPTest.php tests/Messaging/Adapter/SMS/SMSTest.php - tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php + tests/Messaging/Adapter/SMS/WhatsAppTest.php diff --git a/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php b/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php deleted file mode 100644 index 47540d268..000000000 --- a/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppE2ETest.php +++ /dev/null @@ -1,93 +0,0 @@ -businessAccountId = getenv('TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID') ?: ''; - $this->recipient = getenv('TESTS_WHATSAPP_RECIPIENT') ?: ''; - - if ($accessToken === '' || $phoneNumberId === '' || $this->businessAccountId === '' || $this->recipient === '') { - $this->markTestSkipped('Set TESTS_WHATSAPP_ACCESS_TOKEN, TESTS_WHATSAPP_PHONE_NUMBER_ID, TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID and TESTS_WHATSAPP_RECIPIENT to run against Meta.'); - } - - $this->adapter = new WhatsApp($accessToken, $phoneNumberId, self::TEMPLATE, self::LANGUAGE); - } - - public function testUpsertTemplateIsApprovedImmediately(): void - { - $result = $this->adapter->upsertTemplate($this->businessAccountId, [self::LANGUAGE], 10); - - $entries = $result['data'] ?? [$result]; - $this->assertNotEmpty($entries, 'Meta returned no template entry.'); - - $languages = []; - foreach ($entries as $entry) { - $this->assertSame('APPROVED', $entry['status'] ?? null, 'Authentication templates are approved without review.'); - $languages[] = $entry['language'] ?? self::LANGUAGE; - } - - $this->assertContains(self::LANGUAGE, $languages); - } - - #[Depends('testUpsertTemplateIsApprovedImmediately')] - public function testDeliversCodeToAllowListedRecipient(): void - { - $code = (string) random_int(100000, 999999); - - $response = $this->adapter->send(new SMS([$this->recipient], $code)); - - $this->assertResponse($response); - $this->assertSame($this->recipient, $response['results'][0]['recipient']); - } - - #[Depends('testUpsertTemplateIsApprovedImmediately')] - public function testReportsMissingTemplateLanguage(): void - { - $message = new SMS([$this->recipient], '123456'); - $message->setMetadata([MetadataParameter::LANGUAGE->value => 'zu']); - - $response = $this->adapter->send($message); - - $this->assertSame(0, $response['deliveredTo']); - $this->assertSame('failure', $response['results'][0]['status']); - $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'Meta reports a missing template translation as error 132001.'); - } - - public function testReportsRecipientOutsideAllowList(): void - { - $response = $this->adapter->send(new SMS(['+15550000001'], '123456')); - - $this->assertSame(0, $response['deliveredTo']); - $this->assertSame('failure', $response['results'][0]['status']); - $this->assertStringContainsString('131030', (string) $response['results'][0]['error'], 'A test number may only message allow-listed recipients.'); - } -} diff --git a/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php b/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php index accf3293c..400231d9f 100644 --- a/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php +++ b/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php @@ -4,413 +4,70 @@ namespace Utopia\Tests\Adapter\SMS; -use PHPUnit\Framework\Attributes\DataProvider; -use Psr\Http\Client\ClientExceptionInterface; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestInterface; -use Psr\Http\Message\ResponseInterface; use Utopia\Messaging\Adapter\SMS\WhatsApp; -use Utopia\Messaging\Adapter\SMS\WhatsApp\MetadataParameter; use Utopia\Messaging\Messages\SMS; -use Utopia\Psr7\Response; -use Utopia\Psr7\Stream\Factory as StreamFactory; use Utopia\Tests\Adapter\Base; +/** + * Runs against a Meta developer test number, which is free and can message up + * to five allow-listed recipients. The test account ships with sample templates + * only and refuses to create new ones, so there is no authentication template + * to deliver a code with; these tests cover the real request path and Meta's + * error responses instead. Requires TESTS_WHATSAPP_ACCESS_TOKEN, + * TESTS_WHATSAPP_PHONE_NUMBER_ID and an allow-listed TESTS_WHATSAPP_RECIPIENT. + */ final class WhatsAppTest extends Base { - private const string TOKEN = 'EAAG-system-user-token'; + private const string TEMPLATE = 'utopia_messaging_test'; - private const string PHONE_NUMBER_ID = '106540352242922'; + private string $accessToken; - private const string TEMPLATE = 'appwrite_otp'; + private string $phoneNumberId; - public function testDeliversCodeThroughTemplate(): void - { - $client = new RecordingClient(200, [ - 'messaging_product' => 'whatsapp', - 'contacts' => [['input' => '14155551234', 'wa_id' => '14155551234']], - 'messages' => [['id' => 'wamid.HBgL', 'message_status' => 'accepted']], - ]); - $adapter = $this->adapter($client); - - $response = $adapter->send(new SMS(['+1 (415) 555-1234'], '482913')); - - $this->assertResponse($response); - $this->assertSame('+1 (415) 555-1234', $response['results'][0]['recipient']); - - $request = $client->request; - $this->assertInstanceOf(RequestInterface::class, $request); - $this->assertSame('POST', $request->getMethod()); - $this->assertStringContainsString('/' . self::PHONE_NUMBER_ID . '/messages', $request->getUri()->getPath()); - $this->assertSame('Bearer ' . self::TOKEN, $request->getHeaderLine('Authorization')); - - $body = $client->body(); - $this->assertSame('14155551234', $body['to'], 'Recipient must reach Meta as digits only.'); - $this->assertSame(self::TEMPLATE, $body['template']['name']); - $this->assertSame('en_US', $body['template']['language']['code']); - $this->assertSame(['482913', '482913'], $this->codes($body), 'The code must fill both the body and the button.'); - } - - public function testUsesConstructorLanguageAndVersion(): void - { - $client = new RecordingClient(200, ['messages' => [['id' => 'wamid.1']]]); - $adapter = new WhatsApp(self::TOKEN, self::PHONE_NUMBER_ID, self::TEMPLATE, 'pt_BR', 'v23.0', $this->factory($client)); - - $adapter->send(new SMS(['+5511987654321'], '123456')); - - $this->assertInstanceOf(RequestInterface::class, $client->request); - $this->assertStringStartsWith('/v23.0/', $client->request->getUri()->getPath()); - $this->assertSame('pt_BR', $client->body()['template']['language']['code']); - } - - public function testMetadataOverridesLanguage(): void - { - $client = new RecordingClient(200, ['messages' => [['id' => 'wamid.1']]]); - $adapter = $this->adapter($client); - - $message = new SMS(['+33612345678'], '123456'); - $message->setMetadata([MetadataParameter::LANGUAGE->value => 'fr']); - $adapter->send($message); - - $this->assertSame('fr', $client->body()['template']['language']['code']); - } + private string $recipient; - public function testRejectsEmptyLanguageMetadata(): void + protected function setUp(): void { - $adapter = $this->adapter(new RecordingClient(200, [])); + $this->accessToken = getenv('TESTS_WHATSAPP_ACCESS_TOKEN') ?: ''; + $this->phoneNumberId = getenv('TESTS_WHATSAPP_PHONE_NUMBER_ID') ?: ''; + $this->recipient = getenv('TESTS_WHATSAPP_RECIPIENT') ?: ''; - $message = new SMS(['+33612345678'], '123456'); - $message->setMetadata([MetadataParameter::LANGUAGE->value => '']); - - $this->expectException(\InvalidArgumentException::class); - $adapter->send($message); - } - - /** - * @return iterable - */ - public static function invalidCodes(): iterable - { - yield 'empty' => ['']; - yield 'too long' => ['1234567890123456']; - yield 'free text' => ['Your code is 123456']; - yield 'newline' => ["123\n456"]; - yield 'symbols' => ['12-34']; - } - - #[DataProvider('invalidCodes')] - public function testRejectsContentThatIsNotACode(string $content): void - { - $client = new RecordingClient(200, []); - $adapter = $this->adapter($client); - - try { - $adapter->send(new SMS(['+14155551234'], $content)); - $this->fail('Expected an InvalidArgumentException.'); - } catch (\InvalidArgumentException $exception) { - $this->assertStringContainsString('15 letters and digits', $exception->getMessage()); + if ($this->accessToken === '' || $this->phoneNumberId === '' || $this->recipient === '') { + $this->markTestSkipped('Set TESTS_WHATSAPP_ACCESS_TOKEN, TESTS_WHATSAPP_PHONE_NUMBER_ID and TESTS_WHATSAPP_RECIPIENT to run against Meta.'); } - - $this->assertNotInstanceOf(RequestInterface::class, $client->request, 'No request must be sent for an invalid code.'); } - public function testAcceptsAlphanumericCodeAtMaximumLength(): void + public function testReportsMissingTemplate(): void { - $client = new RecordingClient(200, ['messages' => [['id' => 'wamid.1']]]); - $adapter = $this->adapter($client); - - $response = $adapter->send(new SMS(['+14155551234'], 'ABC123XYZ789ABC')); + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); - $this->assertResponse($response); - } - - public function testReportsGraphApiErrorWithCode(): void - { - $client = new RecordingClient(400, [ - 'error' => [ - 'message' => '(#132001) Template name does not exist in the translation', - 'type' => 'OAuthException', - 'code' => 132001, - 'error_data' => [ - 'messaging_product' => 'whatsapp', - 'details' => 'template name (appwrite_otp) does not exist in fr', - ], - 'fbtrace_id' => 'AbC', - ], - ]); - $adapter = $this->adapter($client); - - $response = $adapter->send(new SMS(['+33612345678'], '123456')); + $response = $adapter->send(new SMS([$this->recipient], '123456')); $this->assertSame(0, $response['deliveredTo']); + $this->assertSame($this->recipient, $response['results'][0]['recipient']); $this->assertSame('failure', $response['results'][0]['status']); - $this->assertSame( - 'Error 132001: (#132001) Template name does not exist in the translation: template name (appwrite_otp) does not exist in fr', - $response['results'][0]['error'], - ); + $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'Meta reports an unknown template as error 132001.'); } - public function testReportsUndeliverableRecipient(): void + public function testReportsRecipientOutsideAllowList(): void { - $client = new RecordingClient(400, [ - 'error' => [ - 'message' => 'Message Undeliverable', - 'code' => 131026, - 'error_data' => ['details' => 'Message Undeliverable'], - ], - ]); - $adapter = $this->adapter($client); + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); - $response = $adapter->send(new SMS(['+14155551234'], '123456')); + $response = $adapter->send(new SMS(['+15550000001'], '123456')); - $this->assertSame('Error 131026: Message Undeliverable', $response['results'][0]['error']); + $this->assertSame(0, $response['deliveredTo']); + $this->assertSame('failure', $response['results'][0]['status']); + $this->assertStringContainsString('131030', (string) $response['results'][0]['error'], 'A test number may only message allow-listed recipients.'); } public function testReportsInvalidToken(): void { - $client = new RecordingClient(401, [ - 'error' => ['message' => 'Invalid OAuth access token', 'code' => 190], - ]); - $adapter = $this->adapter($client); - - $response = $adapter->send(new SMS(['+14155551234'], '123456')); - - $this->assertSame('Error 190: Invalid OAuth access token', $response['results'][0]['error']); - } - - public function testReportsUnexpectedBody(): void - { - $client = new RecordingClient(502, 'Bad Gateway'); - $adapter = $this->adapter($client); + $adapter = new WhatsApp('not-a-token', $this->phoneNumberId, self::TEMPLATE); - $response = $adapter->send(new SMS(['+14155551234'], '123456')); - - $this->assertSame('Unknown error', $response['results'][0]['error']); - } - - public function testReportsTransportFailure(): void - { - $client = new RecordingClient(0, null, new TransportException('Could not resolve host')); - $adapter = $this->adapter($client); - - $response = $adapter->send(new SMS(['+14155551234'], '123456')); + $response = $adapter->send(new SMS([$this->recipient], '123456')); $this->assertSame(0, $response['deliveredTo']); - $this->assertSame('Could not resolve host', $response['results'][0]['error']); - } - - public function testRefusesMoreThanOneRecipient(): void - { - $adapter = $this->adapter(new RecordingClient(200, [])); - - $this->expectExceptionMessage('WhatsApp can only send 1 messages per request.'); - $adapter->send(new SMS(['+1', '+2'], '123456')); - } - - public function testRefusesMissingRecipient(): void - { - $client = new RecordingClient(200, []); - $adapter = $this->adapter($client); - - try { - $adapter->send(new SMS([], '123456')); - $this->fail('Expected an InvalidArgumentException.'); - } catch (\InvalidArgumentException $exception) { - $this->assertStringContainsString('exactly one recipient', $exception->getMessage()); - } - - $this->assertNotInstanceOf(RequestInterface::class, $client->request, 'No request must be sent without a recipient.'); - } - - public function testUpsertsTemplateInEveryLanguage(): void - { - $client = new RecordingClient(200, [ - 'data' => [ - ['id' => '1', 'status' => 'APPROVED', 'language' => 'en_US'], - ['id' => '2', 'status' => 'APPROVED', 'language' => 'fr'], - ], - ]); - $adapter = $this->adapter($client); - - $result = $adapter->upsertTemplate('102290129340398', ['en_US', 'fr'], 10, true, 600); - - $this->assertCount(2, $result['data']); - $this->assertInstanceOf(RequestInterface::class, $client->request); - $this->assertStringContainsString('/102290129340398/', $client->request->getUri()->getPath()); - $this->assertSame('Bearer ' . self::TOKEN, $client->request->getHeaderLine('Authorization')); - - $body = $client->body(); - $this->assertSame(self::TEMPLATE, $body['name']); - $this->assertSame('authentication', $body['category']); - $this->assertSame(['en_US', 'fr'], $body['languages']); - $this->assertSame(600, $body['message_send_ttl_seconds']); - $this->assertSame(10, $this->component($body, 'footer')['code_expiration_minutes']); - $this->assertTrue($this->component($body, 'body')['add_security_recommendation']); - } - - public function testUpsertsTemplateWithoutOptionalParts(): void - { - $client = new RecordingClient(200, ['data' => []]); - $adapter = $this->adapter($client); - - $adapter->upsertTemplate('102290129340398', securityRecommendation: false); - - $body = $client->body(); - $this->assertArrayNotHasKey('languages', $body, 'Omitting languages lets Meta create every supported one.'); - $this->assertArrayNotHasKey('message_send_ttl_seconds', $body); - $this->assertNull($this->component($body, 'footer')); - $this->assertFalse($this->component($body, 'body')['add_security_recommendation']); - } - - /** - * @return iterable - */ - public static function validTimeToLive(): iterable - { - yield 'day' => [WhatsApp::TIME_TO_LIVE_DAY]; - yield 'minimum' => [WhatsApp::TIME_TO_LIVE_MIN_SECONDS]; - yield 'maximum' => [WhatsApp::TIME_TO_LIVE_MAX_SECONDS]; - } - - #[DataProvider('validTimeToLive')] - public function testUpsertAcceptsTimeToLiveBoundaries(int $timeToLive): void - { - $client = new RecordingClient(200, ['data' => []]); - $adapter = $this->adapter($client); - - $adapter->upsertTemplate('102290129340398', ['en_US'], timeToLive: $timeToLive); - - $this->assertSame($timeToLive, $client->body()['message_send_ttl_seconds']); - } - - /** - * @return iterable - */ - public static function invalidTimeToLive(): iterable - { - yield 'zero' => [0]; - yield 'below minimum' => [WhatsApp::TIME_TO_LIVE_MIN_SECONDS - 1]; - yield 'above maximum' => [WhatsApp::TIME_TO_LIVE_MAX_SECONDS + 1]; - yield 'other negative' => [-2]; - } - - #[DataProvider('invalidTimeToLive')] - public function testUpsertRejectsTimeToLiveOutOfRange(int $timeToLive): void - { - $client = new RecordingClient(200, ['data' => []]); - $adapter = $this->adapter($client); - - try { - $adapter->upsertTemplate('102290129340398', ['en_US'], timeToLive: $timeToLive); - $this->fail('Expected an InvalidArgumentException.'); - } catch (\InvalidArgumentException $exception) { - $this->assertStringContainsString('time to live', $exception->getMessage()); - } - - $this->assertNotInstanceOf(RequestInterface::class, $client->request, 'No request must be sent for an invalid time to live.'); - } - - public function testUpsertRejectsExpirationOutOfRange(): void - { - $adapter = $this->adapter(new RecordingClient(200, [])); - - $this->expectException(\InvalidArgumentException::class); - $adapter->upsertTemplate('102290129340398', ['en_US'], 91); - } - - public function testUpsertThrowsOnGraphApiError(): void - { - $client = new RecordingClient(400, [ - 'error' => ['message' => 'Unsupported post request', 'code' => 100], - ]); - $adapter = $this->adapter($client); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Error 100: Unsupported post request'); - $adapter->upsertTemplate('bad-id', ['en_US']); - } - - /** - * Every text parameter in the template, in component order. - * - * @param array $body - * @return array - */ - private function codes(array $body): array - { - $codes = []; - foreach ($body['template']['components'] as $component) { - foreach ($component['parameters'] as $parameter) { - $codes[] = $parameter['text']; - } - } - - return $codes; - } - - /** - * @param array $body - * @return array|null - */ - private function component(array $body, string $type): ?array - { - foreach ($body['components'] as $component) { - if ($component['type'] === $type) { - return $component; - } - } - - return null; - } - - private function adapter(RecordingClient $client): WhatsApp - { - return new WhatsApp(self::TOKEN, self::PHONE_NUMBER_ID, self::TEMPLATE, clientFactory: $this->factory($client)); - } - - private function factory(RecordingClient $client): \Closure - { - return static fn(): ClientInterface => $client; - } -} - -final class RecordingClient implements ClientInterface -{ - public ?RequestInterface $request = null; - - /** - * @param array|string|null $response - */ - public function __construct( - private readonly int $statusCode, - private readonly array|string|null $response, - private readonly ?ClientExceptionInterface $exception = null, - ) {} - - public function sendRequest(RequestInterface $request): ResponseInterface - { - $this->request = $request; - - if ($this->exception instanceof ClientExceptionInterface) { - throw $this->exception; - } - - $body = \is_array($this->response) ? json_encode($this->response, JSON_THROW_ON_ERROR) : (string) $this->response; - - return new Response($this->statusCode, '', new StreamFactory()->createStream($body)); - } - - /** - * @return array - */ - public function body(): array - { - if (!$this->request instanceof RequestInterface) { - throw new \LogicException('No request recorded.'); - } - - return json_decode((string) $this->request->getBody(), true, flags: JSON_THROW_ON_ERROR); + $this->assertSame('failure', $response['results'][0]['status']); + $this->assertStringContainsString('190', (string) $response['results'][0]['error'], 'Meta rejects a bad bearer token with error 190.'); } } - -final class TransportException extends \RuntimeException implements ClientExceptionInterface {} From ef103b85fb838737b84e38982edeeaef855c83dc Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 15 Sep 2026 14:36:24 +0100 Subject: [PATCH 5/6] feat(messaging): cover the rest of Meta's authentication-template surface A host will want more than a fixed template and language per adapter: per-message template and language overrides let one instance serve login, verification and MFA flows, and biz_opaque_callback_data lets delivery webhooks be matched back to the record that triggered the send, which is what a fallback to SMS on failure needs. Template creation now supports one-tap and zero-tap autofill with the Android apps Meta requires, and templates can be looked up and deleted so a host can check approval before sending and clean up after itself. --- .github/workflows/tests.yml | 1 + packages/messaging/.env.dev | 1 + packages/messaging/README.md | 20 +- .../Utopia/Messaging/Adapter/SMS/WhatsApp.php | 217 ++++++++++++++---- .../Messaging/Adapter/SMS/WhatsApp/App.php | 51 ++++ .../SMS/WhatsApp/MetadataParameter.php | 12 + .../Adapter/SMS/WhatsApp/OtpType.php | 34 +++ .../Messaging/Adapter/SMS/WhatsAppTest.php | 64 +++++- 8 files changed, 350 insertions(+), 50 deletions(-) create mode 100644 packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/App.php create mode 100644 packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/OtpType.php diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 26c46969e..743539f78 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -127,5 +127,6 @@ jobs: TESTS_BITBUCKET_WORKSPACE: ${{ secrets.TESTS_BITBUCKET_WORKSPACE }} TESTS_WHATSAPP_ACCESS_TOKEN: ${{ secrets.TESTS_WHATSAPP_ACCESS_TOKEN }} TESTS_WHATSAPP_PHONE_NUMBER_ID: ${{ secrets.TESTS_WHATSAPP_PHONE_NUMBER_ID }} + TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID: ${{ secrets.TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID }} TESTS_WHATSAPP_RECIPIENT: ${{ secrets.TESTS_WHATSAPP_RECIPIENT }} run: bin/monorepo test ${{ matrix.package }} ${{ matrix.linked && '--linked' || '' }} diff --git a/packages/messaging/.env.dev b/packages/messaging/.env.dev index 9ec46f9e3..3804579d1 100644 --- a/packages/messaging/.env.dev +++ b/packages/messaging/.env.dev @@ -38,4 +38,5 @@ INFORU_API_TOKEN= INFORU_SENDER_ID= TESTS_WHATSAPP_ACCESS_TOKEN= TESTS_WHATSAPP_PHONE_NUMBER_ID= +TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID= TESTS_WHATSAPP_RECIPIENT= diff --git a/packages/messaging/README.md b/packages/messaging/README.md index 9bb36f91a..4d0854cac 100644 --- a/packages/messaging/README.md +++ b/packages/messaging/README.md @@ -78,17 +78,33 @@ The WhatsApp adapter sends codes through a Meta Cloud API authentication templat use \Utopia\Messaging\Messages\SMS; use \Utopia\Messaging\Adapter\SMS\WhatsApp; +use \Utopia\Messaging\Adapter\SMS\WhatsApp\App; use \Utopia\Messaging\Adapter\SMS\WhatsApp\MetadataParameter; +use \Utopia\Messaging\Adapter\SMS\WhatsApp\OtpType; $messaging = new WhatsApp('YOUR_ACCESS_TOKEN', 'YOUR_PHONE_NUMBER_ID', 'login_code'); -$messaging->upsertTemplate('YOUR_BUSINESS_ACCOUNT_ID', ['en_US', 'fr'], expirationMinutes: 10); +// Copy-code works everywhere. One-tap and zero-tap hand the code straight to a +// named Android app and fall back to copy-code on other platforms. +$messaging->upsertTemplate( + 'YOUR_BUSINESS_ACCOUNT_ID', + ['en_US', 'fr'], + expirationMinutes: 10, + otpType: OtpType::ONE_TAP, + apps: [new App('com.example.app', 'K8a/AINcGX7')], +); + +$messaging->getTemplate('YOUR_BUSINESS_ACCOUNT_ID'); // [['language' => 'en_US', 'status' => 'APPROVED', ...], ...] $message = new SMS( to: ['+12025550139'], content: '482913' ); -$message->setMetadata([MetadataParameter::LANGUAGE->value => 'fr']); +$message->setMetadata([ + MetadataParameter::LANGUAGE->value => 'fr', + MetadataParameter::TEMPLATE->value => 'recovery_code', + MetadataParameter::CALLBACK_DATA->value => 'token-64f1c2', // echoed back in status webhooks +]); $messaging->send($message); ``` diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php index 1b75fe858..e9952f1d4 100644 --- a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php +++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php @@ -7,7 +7,9 @@ use Closure; use Psr\Http\Client\ClientInterface; use Utopia\Messaging\Adapter\SMS as SMSAdapter; +use Utopia\Messaging\Adapter\SMS\WhatsApp\App; use Utopia\Messaging\Adapter\SMS\WhatsApp\MetadataParameter; +use Utopia\Messaging\Adapter\SMS\WhatsApp\OtpType; use Utopia\Messaging\Messages\SMS as SMSMessage; use Utopia\Messaging\Response; @@ -15,6 +17,7 @@ // https://developers.facebook.com/documentation/business-messaging/whatsapp/templates/authentication-templates/authentication-templates/ // https://developers.facebook.com/docs/whatsapp/cloud-api/reference/messages/ // https://developers.facebook.com/docs/whatsapp/cloud-api/support/error-codes +// https://developers.facebook.com/docs/whatsapp/business-management-api/message-templates /** * Delivers one-time passcodes over WhatsApp with a Meta Cloud API authentication template. @@ -53,6 +56,21 @@ class WhatsApp extends SMSAdapter */ public const int TIME_TO_LIVE_DAY = -1; + /** + * Longest correlation string Meta echoes back in status webhooks. + */ + public const int CALLBACK_DATA_MAX_LENGTH = 512; + + /** + * Longest template name Meta accepts. + */ + public const int TEMPLATE_NAME_MAX_LENGTH = 512; + + /** + * Most Android apps one template can hand the code to. + */ + public const int APPS_MAX = 5; + private const string ENDPOINT = 'https://graph.facebook.com'; private const string PRODUCT = 'whatsapp'; @@ -61,16 +79,18 @@ class WhatsApp extends SMSAdapter private const string BUTTON_TYPE = 'otp'; - private const string BUTTON_OTP_TYPE = 'copy_code'; - private const string BUTTON_SUB_TYPE = 'url'; private const string CODE_PATTERN = '/^[A-Za-z0-9]{1,15}$/'; + private const string TEMPLATE_NAME_PATTERN = '/^[a-z0-9_]{1,512}$/'; + + private const string TEMPLATE_FIELDS = 'id,name,language,status,category'; + /** * @param string $accessToken System User access token with the `whatsapp_business_messaging` permission. * @param string $phoneNumberId ID of the business phone number that sends the code. - * @param string $template Name of an approved authentication template. + * @param string $template Name of an approved authentication template. Must be lowercase letters, digits and underscores. * @param string $language Template language code the template was created in. * @param string $version Graph API version to call. * @param (Closure(): ClientInterface)|null $clientFactory Factory for the PSR-18 client used to reach the Graph API; defaults to cURL. @@ -83,6 +103,8 @@ public function __construct( private readonly string $version = self::DEFAULT_VERSION, ?Closure $clientFactory = null, ) { + $this->assertTemplateName($template); + parent::__construct(clientFactory: $clientFactory); } @@ -107,9 +129,11 @@ public function getMaxMessagesPerRequest(): int * @param int|null $expirationMinutes Validity stated in the footer, between 1 and 90; null omits the footer. * @param bool $securityRecommendation Whether the body tells the recipient not to share the code. * @param int|null $timeToLive Seconds after which an undelivered message is dropped, between 60 and 600, or -1 for 24 hours. + * @param OtpType $otpType How the recipient moves the code into the app; one-tap and zero-tap need at least one app. + * @param array $apps Android apps that may receive the code through one-tap or zero-tap autofill, at most five. * @return array Decoded Graph API response. * - * @throws \InvalidArgumentException If the footer validity or time to live is out of range. + * @throws \InvalidArgumentException If an option is outside what Meta accepts. * @throws \RuntimeException If the Graph API rejects the request. */ public function upsertTemplate( @@ -118,6 +142,8 @@ public function upsertTemplate( ?int $expirationMinutes = null, bool $securityRecommendation = true, ?int $timeToLive = null, + OtpType $otpType = OtpType::COPY_CODE, + array $apps = [], ): array { if ($expirationMinutes !== null && ($expirationMinutes < 1 || $expirationMinutes > self::EXPIRATION_MAX_MINUTES)) { throw new \InvalidArgumentException('WhatsApp code expiration must be between 1 and ' . self::EXPIRATION_MAX_MINUTES . ' minutes.'); @@ -127,6 +153,14 @@ public function upsertTemplate( throw new \InvalidArgumentException('WhatsApp time to live must be between ' . self::TIME_TO_LIVE_MIN_SECONDS . ' and ' . self::TIME_TO_LIVE_MAX_SECONDS . ' seconds, or ' . self::TIME_TO_LIVE_DAY . ' for 24 hours.'); } + if ($otpType->requiresApps() && $apps === []) { + throw new \InvalidArgumentException('WhatsApp ' . $otpType->value . ' templates must name at least one app that receives the code.'); + } + + if (\count($apps) > self::APPS_MAX) { + throw new \InvalidArgumentException('WhatsApp templates may name at most ' . self::APPS_MAX . ' apps.'); + } + $components = [ [ 'type' => 'body', @@ -141,14 +175,22 @@ public function upsertTemplate( ]; } + $button = [ + 'type' => self::BUTTON_TYPE, + 'otp_type' => $otpType->value, + ]; + + if ($apps !== []) { + $button['supported_apps'] = array_map(static fn(App $app): array => $app->toArray(), array_values($apps)); + } + + if ($otpType === OtpType::ZERO_TAP) { + $button['zero_tap_terms_accepted'] = true; + } + $components[] = [ 'type' => 'buttons', - 'buttons' => [ - [ - 'type' => self::BUTTON_TYPE, - 'otp_type' => self::BUTTON_OTP_TYPE, - ], - ], + 'buttons' => [$button], ]; $body = [ @@ -179,6 +221,64 @@ public function upsertTemplate( return \is_array($result['response']) ? $result['response'] : []; } + /** + * List every language of a template with its approval status. + * + * @param string $businessAccountId WhatsApp Business Account ID that owns the template. + * @param string|null $name Template name; defaults to the adapter's template. + * @return array + * + * @throws \InvalidArgumentException If the name is not a valid template name. + * @throws \RuntimeException If the Graph API rejects the request. + */ + public function getTemplate(string $businessAccountId, ?string $name = null): array + { + $name ??= $this->template; + $this->assertTemplateName($name); + + $result = $this->request( + method: 'GET', + url: $this->url($businessAccountId, 'message_templates') . '?' . http_build_query([ + 'name' => $name, + 'fields' => self::TEMPLATE_FIELDS, + ]), + headers: $this->headers(), + ); + + if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) { + throw new \RuntimeException($this->error($result)); + } + + $data = \is_array($result['response']) ? ($result['response']['data'] ?? []) : []; + + return \is_array($data) ? array_values($data) : []; + } + + /** + * Delete a template in every language it exists in. + * + * @param string $businessAccountId WhatsApp Business Account ID that owns the template. + * @param string|null $name Template name; defaults to the adapter's template. + * + * @throws \InvalidArgumentException If the name is not a valid template name. + * @throws \RuntimeException If the Graph API rejects the request. + */ + public function deleteTemplate(string $businessAccountId, ?string $name = null): void + { + $name ??= $this->template; + $this->assertTemplateName($name); + + $result = $this->request( + method: 'DELETE', + url: $this->url($businessAccountId, 'message_templates') . '?' . http_build_query(['name' => $name]), + headers: $this->headers(), + ); + + if ($result['statusCode'] < 200 || $result['statusCode'] >= 300) { + throw new \RuntimeException($this->error($result)); + } + } + /** * {@inheritdoc} */ @@ -190,10 +290,23 @@ protected function process(SMSMessage $message): array throw new \InvalidArgumentException('WhatsApp authentication templates only accept a code of up to ' . self::CODE_MAX_LENGTH . ' letters and digits as the message content.'); } - $language = $message->getMetadata()[MetadataParameter::LANGUAGE->value] ?? $this->language; + $metadata = $message->getMetadata() ?? []; + $metadata = array_intersect_key($metadata, array_flip(array_column(MetadataParameter::cases(), 'value'))); - if (!\is_string($language) || $language === '') { - throw new \InvalidArgumentException('WhatsApp language metadata must be a non-empty string.'); + foreach ($metadata as $key => $value) { + if (!\is_string($value) || $value === '') { + throw new \InvalidArgumentException("WhatsApp {$key} metadata must be a non-empty string."); + } + } + + $language = $metadata[MetadataParameter::LANGUAGE->value] ?? $this->language; + $template = $metadata[MetadataParameter::TEMPLATE->value] ?? $this->template; + $callbackData = $metadata[MetadataParameter::CALLBACK_DATA->value] ?? null; + + $this->assertTemplateName($template); + + if ($callbackData !== null && \strlen($callbackData) > self::CALLBACK_DATA_MAX_LENGTH) { + throw new \InvalidArgumentException('WhatsApp callback data must be at most ' . self::CALLBACK_DATA_MAX_LENGTH . ' characters.'); } $to = $message->getTo()[0] ?? null; @@ -202,44 +315,50 @@ protected function process(SMSMessage $message): array throw new \InvalidArgumentException('WhatsApp requires exactly one recipient phone number.'); } - $result = $this->request( - method: 'POST', - url: $this->url($this->phoneNumberId, 'messages'), - headers: $this->headers(), - body: [ - 'messaging_product' => self::PRODUCT, - 'recipient_type' => 'individual', - 'to' => $this->normalize($to), - 'type' => 'template', - 'template' => [ - 'name' => $this->template, - 'language' => [ - 'code' => $language, - ], - 'components' => [ - [ - 'type' => 'body', - 'parameters' => [ - [ - 'type' => 'text', - 'text' => $code, - ], + $body = [ + 'messaging_product' => self::PRODUCT, + 'recipient_type' => 'individual', + 'to' => $this->normalize($to), + 'type' => 'template', + 'template' => [ + 'name' => $template, + 'language' => [ + 'code' => $language, + ], + 'components' => [ + [ + 'type' => 'body', + 'parameters' => [ + [ + 'type' => 'text', + 'text' => $code, ], ], - [ - 'type' => 'button', - 'sub_type' => self::BUTTON_SUB_TYPE, - 'index' => '0', - 'parameters' => [ - [ - 'type' => 'text', - 'text' => $code, - ], + ], + [ + 'type' => 'button', + 'sub_type' => self::BUTTON_SUB_TYPE, + 'index' => '0', + 'parameters' => [ + [ + 'type' => 'text', + 'text' => $code, ], ], ], ], ], + ]; + + if ($callbackData !== null) { + $body['biz_opaque_callback_data'] = $callbackData; + } + + $result = $this->request( + method: 'POST', + url: $this->url($this->phoneNumberId, 'messages'), + headers: $this->headers(), + body: $body, ); $response = new Response($this->getType()); @@ -254,6 +373,16 @@ protected function process(SMSMessage $message): array return $response->toArray(); } + /** + * @throws \InvalidArgumentException If the name is not lowercase letters, digits and underscores. + */ + private function assertTemplateName(string $name): void + { + if (preg_match(self::TEMPLATE_NAME_PATTERN, $name) !== 1) { + throw new \InvalidArgumentException('WhatsApp template names must be 1 to ' . self::TEMPLATE_NAME_MAX_LENGTH . ' lowercase letters, digits and underscores.'); + } + } + /** * The Cloud API wants the number as digits only, country code first, without a plus sign. */ diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/App.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/App.php new file mode 100644 index 000000000..f978c84d9 --- /dev/null +++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/App.php @@ -0,0 +1,51 @@ + self::PACKAGE_NAME_MAX_LENGTH) { + throw new \InvalidArgumentException('WhatsApp app package name must be between 1 and ' . self::PACKAGE_NAME_MAX_LENGTH . ' characters.'); + } + + if (\strlen($signatureHash) !== self::SIGNATURE_HASH_LENGTH) { + throw new \InvalidArgumentException('WhatsApp app signature hash must be exactly ' . self::SIGNATURE_HASH_LENGTH . ' characters.'); + } + } + + /** + * @return array{package_name: string, signature_hash: string} + */ + public function toArray(): array + { + return [ + 'package_name' => $this->packageName, + 'signature_hash' => $this->signatureHash, + ]; + } +} diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php index 77dc1d305..8cd74db7a 100644 --- a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php +++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php @@ -11,4 +11,16 @@ enum MetadataParameter: string * The authentication template must already exist in that language. */ case LANGUAGE = 'language'; + + /** + * Name of an approved authentication template overriding the adapter default for one message, + * so one adapter can serve flows that use different templates. + */ + case TEMPLATE = 'template'; + + /** + * Opaque string Meta echoes back as `biz_opaque_callback_data` in every status webhook for the + * message, so a host can correlate delivery reports with its own records. + */ + case CALLBACK_DATA = 'callbackData'; } diff --git a/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/OtpType.php b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/OtpType.php new file mode 100644 index 000000000..bf7654ef4 --- /dev/null +++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/OtpType.php @@ -0,0 +1,34 @@ +accessToken = getenv('TESTS_WHATSAPP_ACCESS_TOKEN') ?: ''; $this->phoneNumberId = getenv('TESTS_WHATSAPP_PHONE_NUMBER_ID') ?: ''; + $this->businessAccountId = getenv('TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID') ?: ''; $this->recipient = getenv('TESTS_WHATSAPP_RECIPIENT') ?: ''; - if ($this->accessToken === '' || $this->phoneNumberId === '' || $this->recipient === '') { - $this->markTestSkipped('Set TESTS_WHATSAPP_ACCESS_TOKEN, TESTS_WHATSAPP_PHONE_NUMBER_ID and TESTS_WHATSAPP_RECIPIENT to run against Meta.'); + if ($this->accessToken === '' || $this->phoneNumberId === '' || $this->businessAccountId === '' || $this->recipient === '') { + $this->markTestSkipped('Set TESTS_WHATSAPP_ACCESS_TOKEN, TESTS_WHATSAPP_PHONE_NUMBER_ID, TESTS_WHATSAPP_BUSINESS_ACCOUNT_ID and TESTS_WHATSAPP_RECIPIENT to run against Meta.'); } } + public function testListsTemplateLanguagesWithStatus(): void + { + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); + + $templates = $adapter->getTemplate($this->businessAccountId, self::SAMPLE_TEMPLATE); + + $this->assertNotEmpty($templates, 'Every Meta test account ships the hello_world sample template.'); + $this->assertSame(self::SAMPLE_TEMPLATE, $templates[0]['name']); + $this->assertSame('APPROVED', $templates[0]['status']); + $this->assertSame('en_US', $templates[0]['language']); + } + + public function testListsNothingForUnknownTemplate(): void + { + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); + + $this->assertSame([], $adapter->getTemplate($this->businessAccountId)); + } + + public function testRefusesTemplateCreationOnTestAccount(): void + { + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('Error 10'); + $adapter->upsertTemplate($this->businessAccountId, ['en_US']); + } + public function testReportsMissingTemplate(): void { $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); @@ -49,6 +87,24 @@ public function testReportsMissingTemplate(): void $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'Meta reports an unknown template as error 132001.'); } + public function testOverridesTemplateAndLanguagePerMessage(): void + { + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::SAMPLE_TEMPLATE, 'fr'); + + $message = new SMS([$this->recipient], '123456'); + $message->setMetadata([ + MetadataParameter::TEMPLATE->value => self::TEMPLATE, + MetadataParameter::LANGUAGE->value => 'en_US', + MetadataParameter::CALLBACK_DATA->value => 'utopia-messaging-e2e', + ]); + + $response = $adapter->send($message); + + $this->assertSame('failure', $response['results'][0]['status']); + $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'The override, not the constructor template, must reach Meta.'); + $this->assertStringContainsString('en_US', (string) $response['results'][0]['error'], 'The language override must reach Meta.'); + } + public function testReportsRecipientOutsideAllowList(): void { $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); From b11408a4cb589615de9af076ed38e9e65e3ed725 Mon Sep 17 00:00:00 2001 From: Chirag Aggarwal Date: Tue, 15 Sep 2026 14:56:26 +0100 Subject: [PATCH 6/6] test(messaging): assert the adapter's contract, not Meta's sample configuration The suite pinned the sample template's approval status and language, the exact refusal code for template creation, and Meta's error prose. Those are provider details that can change without an adapter regression. It now checks what a caller relies on: the requested template's languages come back with a status, rejections surface as exceptions carrying a numeric code, and a per-message template override reaches Meta. --- .../Messaging/Adapter/SMS/WhatsAppTest.php | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php b/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php index be7f927bd..fec569c16 100644 --- a/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php +++ b/packages/messaging/tests/Messaging/Adapter/SMS/WhatsAppTest.php @@ -47,16 +47,18 @@ protected function setUp(): void } } - public function testListsTemplateLanguagesWithStatus(): void + public function testListsEveryLanguageOfATemplateWithItsStatus(): void { $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); $templates = $adapter->getTemplate($this->businessAccountId, self::SAMPLE_TEMPLATE); $this->assertNotEmpty($templates, 'Every Meta test account ships the hello_world sample template.'); - $this->assertSame(self::SAMPLE_TEMPLATE, $templates[0]['name']); - $this->assertSame('APPROVED', $templates[0]['status']); - $this->assertSame('en_US', $templates[0]['language']); + foreach ($templates as $template) { + $this->assertSame(self::SAMPLE_TEMPLATE, $template['name'], 'Only the requested template may be returned.'); + $this->assertNotSame('', $template['language'], 'Each entry is one language of the template.'); + $this->assertNotSame('', $template['status'], 'Each entry carries its approval status.'); + } } public function testListsNothingForUnknownTemplate(): void @@ -66,12 +68,12 @@ public function testListsNothingForUnknownTemplate(): void $this->assertSame([], $adapter->getTemplate($this->businessAccountId)); } - public function testRefusesTemplateCreationOnTestAccount(): void + public function testSurfacesRejectedTemplateCreationAsException(): void { $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Error 10'); + $this->expectExceptionMessageMatches('/^Error \\d+: /'); $adapter->upsertTemplate($this->businessAccountId, ['en_US']); } @@ -87,9 +89,9 @@ public function testReportsMissingTemplate(): void $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'Meta reports an unknown template as error 132001.'); } - public function testOverridesTemplateAndLanguagePerMessage(): void + public function testOverridesTemplatePerMessage(): void { - $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::SAMPLE_TEMPLATE, 'fr'); + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::SAMPLE_TEMPLATE); $message = new SMS([$this->recipient], '123456'); $message->setMetadata([ @@ -101,8 +103,7 @@ public function testOverridesTemplateAndLanguagePerMessage(): void $response = $adapter->send($message); $this->assertSame('failure', $response['results'][0]['status']); - $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'The override, not the constructor template, must reach Meta.'); - $this->assertStringContainsString('en_US', (string) $response['results'][0]['error'], 'The language override must reach Meta.'); + $this->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'The constructor template exists, so an unknown-template error proves the override reached Meta.'); } public function testReportsRecipientOutsideAllowList(): void