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/.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/.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/README.md b/packages/messaging/README.md index ac0deabfe..4d0854cac 100644 --- a/packages/messaging/README.md +++ b/packages/messaging/README.md @@ -69,6 +69,46 @@ $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, + 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', + MetadataParameter::TEMPLATE->value => 'recovery_code', + MetadataParameter::CALLBACK_DATA->value => 'token-64f1c2', // echoed back in status webhooks +]); + +$messaging->send($message); +``` + ## Push ```php @@ -118,6 +158,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/phpunit.xml b/packages/messaging/phpunit.xml index 848cf196d..dd543dc65 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/WhatsAppTest.php tests/Messaging/Adapter/Email/EmailTest.php tests/Messaging/Adapter/Email/SMTPTest.php tests/Messaging/Adapter/SMS/SMSTest.php + tests/Messaging/Adapter/SMS/WhatsAppTest.php 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..e9952f1d4 --- /dev/null +++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp.php @@ -0,0 +1,446 @@ +assertTemplateName($template); + + parent::__construct(clientFactory: $clientFactory); + } + + public function getName(): string + { + return static::NAME; + } + + public function getMaxMessagesPerRequest(): int + { + return 1; + } + + /** + * Create or update the authentication template in every given language. + * + * Meta fixes the body text of authentication templates and approves them + * without review, so the response normally reports each language as approved. + * + * @param string $businessAccountId WhatsApp Business Account ID that owns the template. + * @param array $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. + * @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 an option is outside what Meta accepts. + * @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, + 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.'); + } + + 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.'); + } + + 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', + 'add_security_recommendation' => $securityRecommendation, + ], + ]; + + if ($expirationMinutes !== null) { + $components[] = [ + 'type' => 'footer', + 'code_expiration_minutes' => $expirationMinutes, + ]; + } + + $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' => [$button], + ]; + + $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'] : []; + } + + /** + * 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} + */ + 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.'); + } + + $metadata = $message->getMetadata() ?? []; + $metadata = array_intersect_key($metadata, array_flip(array_column(MetadataParameter::cases(), 'value'))); + + 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; + + if (!\is_string($to) || $to === '') { + throw new \InvalidArgumentException('WhatsApp requires exactly one recipient phone number.'); + } + + $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, + ], + ], + ], + ], + ], + ]; + + 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()); + + if ($result['statusCode'] >= 200 && $result['statusCode'] < 300) { + $response->setDeliveredTo(1); + $response->addResult($to); + } else { + $response->addResult($to, $this->error($result)); + } + + 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. + */ + 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/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 new file mode 100644 index 000000000..8cd74db7a --- /dev/null +++ b/packages/messaging/src/Utopia/Messaging/Adapter/SMS/WhatsApp/MetadataParameter.php @@ -0,0 +1,26 @@ +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->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 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.'); + 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 + { + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); + + $this->assertSame([], $adapter->getTemplate($this->businessAccountId)); + } + + public function testSurfacesRejectedTemplateCreationAsException(): void + { + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessageMatches('/^Error \\d+: /'); + $adapter->upsertTemplate($this->businessAccountId, ['en_US']); + } + + public function testReportsMissingTemplate(): void + { + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); + + $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->assertStringContainsString('132001', (string) $response['results'][0]['error'], 'Meta reports an unknown template as error 132001.'); + } + + public function testOverridesTemplatePerMessage(): void + { + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::SAMPLE_TEMPLATE); + + $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 constructor template exists, so an unknown-template error proves the override reached Meta.'); + } + + public function testReportsRecipientOutsideAllowList(): void + { + $adapter = new WhatsApp($this->accessToken, $this->phoneNumberId, self::TEMPLATE); + + $response = $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.'); + } + + public function testReportsInvalidToken(): void + { + $adapter = new WhatsApp('not-a-token', $this->phoneNumberId, self::TEMPLATE); + + $response = $adapter->send(new SMS([$this->recipient], '123456')); + + $this->assertSame(0, $response['deliveredTo']); + $this->assertSame('failure', $response['results'][0]['status']); + $this->assertStringContainsString('190', (string) $response['results'][0]['error'], 'Meta rejects a bad bearer token with error 190.'); + } +}