From 5f5419d05df41415f397d2bbf7b521eada8b4232 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Sun, 14 Jun 2026 10:29:44 +0800 Subject: [PATCH 1/3] Update Fleetbase blog RSS source --- src/Support/FleetbaseBlog.php | 4 ++-- tests/Unit/FleetbaseBlogTest.php | 7 ++++--- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/Support/FleetbaseBlog.php b/src/Support/FleetbaseBlog.php index 73dbe327..7259b98a 100644 --- a/src/Support/FleetbaseBlog.php +++ b/src/Support/FleetbaseBlog.php @@ -62,7 +62,7 @@ public static function normalizeLink(?string $link, ?string $blogUrl = null): st $host = parse_url($link, PHP_URL_HOST); $path = trim((string) parse_url($link, PHP_URL_PATH), '/'); - if ($host && Str::contains($host, 'ghost.io') && $path) { + if ($host && (Str::contains($host, 'ghost.io') || $host === 'blog.fleetbase.io') && $path) { return $blogUrl . '/' . $path; } @@ -74,7 +74,7 @@ public static function normalizeLink(?string $link, ?string $blogUrl = null): st */ public static function getFeedUrl(?string $feedUrl = null): string { - return rtrim($feedUrl ?: getenv('FLEETBASE_BLOG_FEED_URL') ?: 'https://fleetbase.ghost.io/rss/', '/') . '/'; + return rtrim($feedUrl ?: getenv('FLEETBASE_BLOG_FEED_URL') ?: 'https://blog.fleetbase.io/rss/', '/') . '/'; } /** diff --git a/tests/Unit/FleetbaseBlogTest.php b/tests/Unit/FleetbaseBlogTest.php index 1ff3ba31..1aecf17f 100644 --- a/tests/Unit/FleetbaseBlogTest.php +++ b/tests/Unit/FleetbaseBlogTest.php @@ -11,7 +11,7 @@ function fleetbaseBlogRssFixture(): string <![CDATA[First Ghost Post]]> First excerpt.

]]>
- https://fleetbase.ghost.io/first-ghost-post/ + https://blog.fleetbase.io/first-ghost-post/ ghost-post-1 Wed, 06 May 2026 14:31:46 GMT @@ -21,7 +21,7 @@ function fleetbaseBlogRssFixture(): string <![CDATA[Second Ghost Post]]> Second excerpt.

]]>
- https://fleetbase.ghost.io/second-ghost-post/ + https://blog.fleetbase.io/second-ghost-post/ ghost-post-2 Wed, 06 May 2026 14:30:46 GMT @@ -63,5 +63,6 @@ function fleetbaseBlogRssFixture(): string test('fleetbase blog link normalization keeps non ghost links unchanged', function () { expect(FleetbaseBlog::normalizeLink('https://www.fleetbase.io/blog/already-canonical'))->toBe('https://www.fleetbase.io/blog/already-canonical') - ->and(FleetbaseBlog::normalizeLink('https://fleetbase.ghost.io/ghost-post/'))->toBe('https://www.fleetbase.io/blog/ghost-post'); + ->and(FleetbaseBlog::normalizeLink('https://fleetbase.ghost.io/legacy-ghost-post/'))->toBe('https://www.fleetbase.io/blog/legacy-ghost-post') + ->and(FleetbaseBlog::normalizeLink('https://blog.fleetbase.io/ghost-post/'))->toBe('https://www.fleetbase.io/blog/ghost-post'); }); From 8ebeb241b44ca24e6363be59db0a7a0603f0334c Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Mon, 15 Jun 2026 12:58:26 +0800 Subject: [PATCH 2/3] bumped version to v1.6.52 --- composer.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/composer.json b/composer.json index d1c78acb..45482043 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "fleetbase/core-api", - "version": "1.6.51", + "version": "1.6.52", "description": "Core Framework and Resources for Fleetbase API", "keywords": [ "fleetbase", From 622fb0252e4e205bfb6df5e56782bc9e1e865fc5 Mon Sep 17 00:00:00 2001 From: "Ronald A. Richardson" Date: Tue, 16 Jun 2026 13:42:47 +0800 Subject: [PATCH 3/3] Added new sms providers (vonage, sns, messagebird) + protocol configuration based providers --- config/sms.php | 55 +++- src/Exceptions/Handler.php | 16 +- .../Internal/v1/SettingController.php | 104 ++++++- src/Services/AwsSnsSmsService.php | 97 +++++++ src/Services/CallProSmsService.php | 8 +- src/Services/CustomHttpSmsService.php | 108 +++++++ src/Services/MessageBirdSmsService.php | 105 +++++++ src/Services/SmppGatewayClient.php | 136 +++++++++ src/Services/SmppSmsService.php | 82 ++++++ src/Services/SmsService.php | 95 ++++++- src/Services/VonageSmsService.php | 98 +++++++ src/Support/EnvironmentMapper.php | 22 ++ src/routes.php | 1 + tests/Unit/MultiProviderSmsServiceTest.php | 268 ++++++++++++++++++ 14 files changed, 1180 insertions(+), 15 deletions(-) create mode 100644 src/Services/AwsSnsSmsService.php create mode 100644 src/Services/CustomHttpSmsService.php create mode 100644 src/Services/MessageBirdSmsService.php create mode 100644 src/Services/SmppGatewayClient.php create mode 100644 src/Services/SmppSmsService.php create mode 100644 src/Services/VonageSmsService.php create mode 100644 tests/Unit/MultiProviderSmsServiceTest.php diff --git a/config/sms.php b/config/sms.php index 10aef1e2..6448cedb 100644 --- a/config/sms.php +++ b/config/sms.php @@ -9,7 +9,8 @@ | This option controls the default SMS provider that will be used to send | messages. You may set this to any of the providers defined below. | - | Supported: "twilio", "callpro" + | Supported: "twilio", "callpro", "vonage", "messagebird", "aws_sns", + | "smpp", "custom_http" | */ @@ -68,5 +69,57 @@ 'callpro' => [ 'enabled' => env('CALLPRO_ENABLED', true), ], + + 'vonage' => [ + 'enabled' => env('VONAGE_SMS_ENABLED', false), + 'api_key' => env('VONAGE_API_KEY', ''), + 'api_secret' => env('VONAGE_API_SECRET', ''), + 'from' => env('VONAGE_SMS_FROM', ''), + 'base_url' => env('VONAGE_SMS_BASE_URL', 'https://rest.nexmo.com/sms/json'), + ], + + 'messagebird' => [ + 'enabled' => env('MESSAGEBIRD_SMS_ENABLED', false), + 'access_key' => env('MESSAGEBIRD_ACCESS_KEY', ''), + 'originator' => env('MESSAGEBIRD_ORIGINATOR', ''), + 'base_url' => env('MESSAGEBIRD_SMS_BASE_URL', 'https://rest.messagebird.com/messages'), + ], + + 'aws_sns' => [ + 'enabled' => env('AWS_SNS_SMS_ENABLED', false), + 'key' => env('AWS_SNS_ACCESS_KEY_ID', env('AWS_ACCESS_KEY_ID')), + 'secret' => env('AWS_SNS_SECRET_ACCESS_KEY', env('AWS_SECRET_ACCESS_KEY')), + 'region' => env('AWS_SNS_REGION', env('AWS_DEFAULT_REGION', 'us-east-1')), + 'sender_id' => env('AWS_SNS_SMS_SENDER_ID', ''), + 'sms_type' => env('AWS_SNS_SMS_TYPE', 'Transactional'), + ], + + 'smpp' => [ + 'enabled' => env('SMPP_SMS_ENABLED', false), + 'host' => env('SMPP_HOST', ''), + 'port' => env('SMPP_PORT', 2775), + 'tls' => env('SMPP_TLS', false), + 'system_id' => env('SMPP_SYSTEM_ID', ''), + 'password' => env('SMPP_PASSWORD', ''), + 'system_type' => env('SMPP_SYSTEM_TYPE', ''), + 'source_addr' => env('SMPP_SOURCE_ADDR', ''), + 'bind_type' => env('SMPP_BIND_TYPE', 'transceiver'), + 'interface_version' => env('SMPP_INTERFACE_VERSION', 0x34), + 'timeout' => env('SMPP_TIMEOUT', 10), + ], + + 'custom_http' => [ + 'enabled' => env('CUSTOM_HTTP_SMS_ENABLED', false), + 'url' => env('CUSTOM_HTTP_SMS_URL', ''), + 'from' => env('CUSTOM_HTTP_SMS_FROM', ''), + 'auth_header' => env('CUSTOM_HTTP_SMS_AUTH_HEADER', ''), + 'auth_token' => env('CUSTOM_HTTP_SMS_AUTH_TOKEN', ''), + 'headers' => [], + 'body' => [ + 'to' => '{{to}}', + 'text' => '{{text}}', + 'from' => '{{from}}', + ], + ], ], ]; diff --git a/src/Exceptions/Handler.php b/src/Exceptions/Handler.php index 652307c5..4e630c3c 100644 --- a/src/Exceptions/Handler.php +++ b/src/Exceptions/Handler.php @@ -4,7 +4,9 @@ use Fleetbase\Support\Utils; use Illuminate\Auth\AuthenticationException; +use Illuminate\Database\Eloquent\ModelNotFoundException; use Illuminate\Foundation\Exceptions\Handler as ExceptionHandler; +use Illuminate\Support\Str; class Handler extends ExceptionHandler { @@ -136,7 +138,7 @@ public function getCloudwatchLoggableException(\Throwable $exception) private function shouldManuallyHandleException(\Throwable $exception): bool { $type = Utils::classBasename($exception); - $exceptions = ['TokenMismatchException', 'ThrottleRequestsException', 'AuthenticationException', 'NotFoundHttpException', 'FleetbaseRequestValidationException']; + $exceptions = ['TokenMismatchException', 'ThrottleRequestsException', 'AuthenticationException', 'NotFoundHttpException', 'FleetbaseRequestValidationException', 'ModelNotFoundException']; return in_array($type, $exceptions); } @@ -165,6 +167,9 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht case 'NotFoundHttpException': return response()->error('There is nothing to see here.'); + case 'ModelNotFoundException': + return response()->error($this->modelNotFoundMessage($exception), 404); + case 'FleetbaseRequestValidationException': /** @var FleetbaseRequestValidationException $exception */ return response()->error($exception->getErrors()); @@ -173,4 +178,13 @@ private function manuallyHandleException(\Throwable $exception): ?\Illuminate\Ht return response()->error($exception->getMessage()); } } + + private function modelNotFoundMessage(\Throwable $exception): string + { + if ($exception instanceof ModelNotFoundException && $exception->getModel()) { + return Str::headline(class_basename($exception->getModel())) . ' not found.'; + } + + return 'Requested resource not found.'; + } } diff --git a/src/Http/Controllers/Internal/v1/SettingController.php b/src/Http/Controllers/Internal/v1/SettingController.php index c1ae16f3..c74394cf 100644 --- a/src/Http/Controllers/Internal/v1/SettingController.php +++ b/src/Http/Controllers/Internal/v1/SettingController.php @@ -7,6 +7,7 @@ use Fleetbase\Models\File; use Fleetbase\Models\Setting; use Fleetbase\Notifications\TestPushNotification; +use Fleetbase\Services\SmsService; use Fleetbase\Support\Utils; use Illuminate\Http\Request; use Illuminate\Notifications\AnonymousNotifiable; @@ -461,6 +462,7 @@ public function getServicesConfig(AdminRequest $request) $twilioSid = config('services.twilio.sid', env('TWILIO_SID')); $twilioToken = config('services.twilio.token', env('TWILIO_TOKEN')); $twilioFrom = config('services.twilio.from', env('TWILIO_FROM')); + $sms = $this->getSmsServicesConfig(); /** sentry service */ $sentryDsn = config('sentry.dsn', env('SENTRY_LARAVEL_DSN', env('SENTRY_DSN'))); @@ -475,6 +477,7 @@ public function getServicesConfig(AdminRequest $request) 'twilioSid' => $twilioSid, 'twilioToken' => $twilioToken, 'twilioFrom' => $twilioFrom, + 'sms' => $sms, 'sentryDsn' => $sentryDsn, ]); } @@ -486,16 +489,24 @@ public function getServicesConfig(AdminRequest $request) */ public function saveServicesConfig(AdminRequest $request) { - $aws = $request->input('aws', config('services.aws')); - $ipinfo = $request->input('ipinfo', config('services.ipinfo')); - $googleMaps = $request->input('googleMaps', config('services.google_maps')); - $twilio = $request->input('twilio', config('services.twilio')); - $sentry = $request->input('sentry', config('sentry.dsn')); + $aws = $request->input('aws', config('services.aws')); + $ipinfo = $request->input('ipinfo', config('services.ipinfo')); + $googleMaps = $request->input('googleMaps', config('services.google_maps')); + $twilio = $request->input('twilio', config('services.twilio')); + $sentry = $request->input('sentry', config('sentry.dsn')); + $smsProviders = $request->input('sms.providers', config('services.sms.providers', config('sms.providers', []))); + $smsDefaultProvider = $request->input('sms.defaultProvider', config('sms.default_provider')); + $smsRoutingRules = $request->input('sms.routingRules', config('sms.routing_rules', [])); Setting::configureSystem('services.aws', array_merge(config('services.aws', []), $aws)); Setting::configureSystem('services.ipinfo', array_merge(config('services.ipinfo', []), $ipinfo)); Setting::configureSystem('services.google_maps', array_merge(config('services.google_maps', []), $googleMaps)); Setting::configureSystem('services.twilio', array_merge(config('services.twilio', []), $twilio)); + Setting::configureSystem('services.sms', array_merge(config('services.sms', []), [ + 'providers' => $smsProviders, + ])); + Setting::configureSystem('sms.default_provider', $smsDefaultProvider); + Setting::configureSystem('sms.routing_rules', $smsRoutingRules); Setting::configureSystem('services.sentry', array_merge(config('sentry', []), $sentry)); // Refresh config @@ -504,6 +515,47 @@ public function saveServicesConfig(AdminRequest $request) return response()->json(['status' => 'OK']); } + /** + * Sends a test SMS message using any configured SMS provider. + * + * @return \Illuminate\Http\JsonResponse + */ + public function testSmsProviderConfig(AdminRequest $request) + { + $provider = $request->input('provider', config('sms.default_provider', SmsService::PROVIDER_TWILIO)); + $phone = $request->input('phone'); + $message = $request->input('message', 'This is a Fleetbase SMS test.'); + $config = $request->input('config', []); + + if (!$phone) { + return response()->json(['status' => 'error', 'message' => 'No test phone number provided!']); + } + + $this->setTemporarySmsProviderConfig($provider, is_array($config) ? $config : []); + + $status = 'success'; + $responseMessage = 'SMS configuration is successful, SMS sent to ' . $phone . '.'; + $result = null; + + try { + $result = (new SmsService())->send($phone, $message, [], $provider); + if (is_array($result) && data_get($result, 'success') === false) { + $status = 'error'; + $responseMessage = data_get($result, 'error', 'SMS provider returned an error.'); + } + } catch (\Throwable $e) { + $responseMessage = $e->getMessage(); + $status = 'error'; + } + + return response()->json([ + 'status' => $status, + 'message' => $responseMessage, + 'provider' => $provider, + 'result' => $result, + ]); + } + /** * Loads and sends the notification channel configurations. * @@ -766,6 +818,48 @@ public function testTwilioConfig(AdminRequest $request) return response()->json(['status' => $status, 'message' => $message]); } + protected function getSmsServicesConfig(): array + { + $providers = array_replace_recursive(config('sms.providers', []), config('services.sms.providers', [])); + + if (!isset($providers[SmsService::PROVIDER_TWILIO])) { + $providers[SmsService::PROVIDER_TWILIO] = []; + } + + $providers[SmsService::PROVIDER_TWILIO] = array_replace_recursive($providers[SmsService::PROVIDER_TWILIO], config('services.twilio', [])); + + return [ + 'defaultProvider' => config('sms.default_provider', SmsService::PROVIDER_TWILIO), + 'routingRules' => config('sms.routing_rules', []), + 'providers' => $providers, + 'available' => (new SmsService())->getAvailableProviders(), + ]; + } + + protected function setTemporarySmsProviderConfig(string $provider, array $providerConfig): void + { + $providers = array_replace_recursive(config('sms.providers', []), config('services.sms.providers', [])); + $providers[$provider] = array_replace_recursive($providers[$provider] ?? [], $providerConfig); + + config([ + 'services.sms.providers' => $providers, + 'sms.providers' => array_replace_recursive(config('sms.providers', []), $providers), + ]); + + if ($provider === SmsService::PROVIDER_TWILIO) { + config([ + 'services.twilio' => array_replace_recursive(config('services.twilio', []), $providerConfig), + 'twilio.twilio.connections.twilio' => array_replace_recursive(config('twilio.twilio.connections.twilio', []), $providerConfig), + ]); + } + + if ($provider === SmsService::PROVIDER_CALLPRO) { + config([ + 'services.callpromn' => array_replace_recursive(config('services.callpromn', []), $providerConfig), + ]); + } + } + /** * Sends a test exception to Sentry. * diff --git a/src/Services/AwsSnsSmsService.php b/src/Services/AwsSnsSmsService.php new file mode 100644 index 00000000..7f688e9e --- /dev/null +++ b/src/Services/AwsSnsSmsService.php @@ -0,0 +1,97 @@ +config = array_merge($awsConfig, $smsConfig); + $this->client = $client; + } + + public function send(string $to, string $text, ?string $from = null, array $options = []): array + { + $this->validateParameters($to, $text); + + $params = [ + 'PhoneNumber' => $to, + 'Message' => $text, + ]; + + $senderId = $from ?: data_get($this->config, 'sender_id'); + $smsType = data_get($options, 'sms_type', data_get($this->config, 'sms_type', 'Transactional')); + + $attributes = array_filter([ + 'AWS.SNS.SMS.SenderID' => $senderId ? [ + 'DataType' => 'String', + 'StringValue' => $senderId, + ] : null, + 'AWS.SNS.SMS.SMSType' => $smsType ? [ + 'DataType' => 'String', + 'StringValue' => $smsType, + ] : null, + ]); + + if (!empty($attributes)) { + $params['MessageAttributes'] = $attributes; + } + + Log::info('Sending SMS via AWS SNS', ['to' => $to]); + + $result = $this->client()->publish($params); + + return [ + 'success' => true, + 'message_id' => $result->get('MessageId'), + 'result' => 'SUCCESS', + 'status' => 'sent', + 'response' => $result->toArray(), + ]; + } + + public function isConfigured(): bool + { + return !empty(data_get($this->config, 'key')) && !empty(data_get($this->config, 'secret')) && !empty(data_get($this->config, 'region')); + } + + protected function client(): SnsClient + { + if ($this->client) { + return $this->client; + } + + return $this->client = new SnsClient([ + 'version' => 'latest', + 'region' => data_get($this->config, 'region', env('AWS_DEFAULT_REGION', 'us-east-1')), + 'credentials' => [ + 'key' => data_get($this->config, 'key', env('AWS_ACCESS_KEY_ID')), + 'secret' => data_get($this->config, 'secret', env('AWS_SECRET_ACCESS_KEY')), + ], + ]); + } + + protected function validateParameters(string $to, string $text): void + { + if (!$this->isConfigured()) { + throw new \InvalidArgumentException('AWS SNS SMS provider is not configured'); + } + + if (empty($to)) { + throw new \InvalidArgumentException('Recipient phone number (to) is required'); + } + + if (empty($text)) { + throw new \InvalidArgumentException('Message text cannot be empty'); + } + } +} diff --git a/src/Services/CallProSmsService.php b/src/Services/CallProSmsService.php index 468e464d..d39cd830 100644 --- a/src/Services/CallProSmsService.php +++ b/src/Services/CallProSmsService.php @@ -18,9 +18,11 @@ class CallProSmsService */ public function __construct() { - $this->apiKey = config('services.callpromn.api_key', ''); - $this->from = config('services.callpromn.from', ''); - $this->baseUrl = config('services.callpromn.base_url', 'https://api-text.callpro.mn/v1/sms'); + $config = array_replace_recursive(config('services.callpromn', []), config('services.sms.providers.callpro', [])); + + $this->apiKey = data_get($config, 'api_key', ''); + $this->from = data_get($config, 'from', ''); + $this->baseUrl = data_get($config, 'base_url', 'https://api-text.callpro.mn/v1/sms'); Log::info('CallProSmsService initialized', [ 'base_url' => $this->baseUrl, diff --git a/src/Services/CustomHttpSmsService.php b/src/Services/CustomHttpSmsService.php new file mode 100644 index 00000000..6e717357 --- /dev/null +++ b/src/Services/CustomHttpSmsService.php @@ -0,0 +1,108 @@ +config = $config ?? config('services.sms.providers.custom_http', config('sms.providers.custom_http', [])); + } + + public function send(string $to, string $text, ?string $from = null, array $options = []): array + { + $this->validateParameters($to, $text); + + $variables = [ + 'to' => $to, + 'text' => $text, + 'from' => $from ?: data_get($this->config, 'from', ''), + 'provider' => SmsService::PROVIDER_CUSTOM_HTTP, + 'timestamp' => date('c'), + 'unique_id' => data_get($options, 'unique_id', data_get($options, 'reference', '')), + ]; + + $url = $this->renderTemplate((string) data_get($this->config, 'url'), $variables); + $headers = $this->renderTemplateValues((array) data_get($this->config, 'headers', []), $variables); + $body = $this->renderTemplateValues((array) data_get($this->config, 'body', [ + 'to' => '{{to}}', + 'text' => '{{text}}', + 'from' => '{{from}}', + ]), $variables); + + $authHeaderName = data_get($this->config, 'auth_header'); + $authHeaderValue = data_get($this->config, 'auth_token'); + if ($authHeaderName && $authHeaderValue) { + $headers[$authHeaderName] = $this->renderTemplate((string) $authHeaderValue, $variables); + } + + Log::info('Sending SMS via custom HTTP gateway', ['to' => $to, 'url' => $url]); + + $response = Http::withHeaders($headers)->asJson()->post($url, $body); + $payload = $response->json(); + + if ($response->successful()) { + return [ + 'success' => true, + 'message_id' => data_get($payload, data_get($this->config, 'message_id_path', 'message_id')), + 'result' => data_get($payload, data_get($this->config, 'status_path', 'status'), 'sent'), + 'status' => data_get($payload, data_get($this->config, 'status_path', 'status'), 'sent'), + 'response' => $payload, + ]; + } + + return [ + 'success' => false, + 'error' => data_get($payload, data_get($this->config, 'error_path', 'error'), "Custom HTTP gateway failed with status code: {$response->status()}"), + 'code' => $response->status(), + 'response' => $payload, + ]; + } + + public function isConfigured(): bool + { + return !empty(data_get($this->config, 'url')); + } + + protected function validateParameters(string $to, string $text): void + { + if (!$this->isConfigured()) { + throw new \InvalidArgumentException('Custom HTTP SMS gateway is not configured'); + } + + if (empty($to)) { + throw new \InvalidArgumentException('Recipient phone number (to) is required'); + } + + if (empty($text)) { + throw new \InvalidArgumentException('Message text cannot be empty'); + } + } + + protected function renderTemplateValues(array $values, array $variables): array + { + foreach ($values as $key => $value) { + if (is_array($value)) { + $values[$key] = $this->renderTemplateValues($value, $variables); + } elseif (is_string($value)) { + $values[$key] = $this->renderTemplate($value, $variables); + } + } + + return $values; + } + + protected function renderTemplate(string $template, array $variables): string + { + foreach ($variables as $key => $value) { + $template = str_replace('{{' . $key . '}}', (string) $value, $template); + } + + return $template; + } +} diff --git a/src/Services/MessageBirdSmsService.php b/src/Services/MessageBirdSmsService.php new file mode 100644 index 00000000..0baf7785 --- /dev/null +++ b/src/Services/MessageBirdSmsService.php @@ -0,0 +1,105 @@ +accessKey = (string) data_get($config, 'access_key', ''); + $this->originator = (string) data_get($config, 'originator', data_get($config, 'from', '')); + $this->baseUrl = rtrim((string) data_get($config, 'base_url', 'https://rest.messagebird.com/messages'), '/'); + } + + public function send(string $to, string $text, ?string $originator = null, array $options = []): array + { + $originator = $originator ?: $this->originator; + $this->validateParameters($to, $text, $originator); + + $payload = array_filter([ + 'originator' => $originator, + 'recipients' => [$this->normalizeRecipient($to)], + 'body' => $text, + 'reference' => data_get($options, 'unique_id', data_get($options, 'reference')), + 'datacoding' => data_get($options, 'datacoding'), + ], static fn ($value) => $value !== null && $value !== ''); + + Log::info('Sending SMS via MessageBird', [ + 'to' => $to, + 'originator' => $originator, + ]); + + $response = Http::withHeaders([ + 'Authorization' => 'AccessKey ' . $this->accessKey, + 'Accept' => 'application/json', + ])->asJson()->post($this->baseUrl, $payload); + + $body = $response->json(); + + if ($response->successful() && is_array($body) && isset($body['id'])) { + return [ + 'success' => true, + 'message_id' => $body['id'], + 'result' => data_get($body, 'recipients.items.0.status', 'sent'), + 'status' => data_get($body, 'recipients.items.0.status', 'sent'), + 'response' => $body, + ]; + } + + return [ + 'success' => false, + 'error' => $this->getErrorMessage($response->status(), is_array($body) ? $body : null), + 'code' => $response->status(), + 'response' => $body, + ]; + } + + public function isConfigured(): bool + { + return !empty($this->accessKey) && !empty($this->originator); + } + + protected function validateParameters(string $to, string $text, string $originator): void + { + if (!$this->isConfigured()) { + throw new \InvalidArgumentException('MessageBird SMS provider is not configured'); + } + + if (empty($to)) { + throw new \InvalidArgumentException('Recipient phone number (to) is required'); + } + + if (empty($text)) { + throw new \InvalidArgumentException('Message text cannot be empty'); + } + + if (empty($originator)) { + throw new \InvalidArgumentException('MessageBird originator is required'); + } + } + + protected function normalizeRecipient(string $to): string + { + return ltrim(preg_replace('/[^0-9+]/', '', $to), '+'); + } + + protected function getErrorMessage(int $statusCode, ?array $body = null): string + { + $errors = data_get($body, 'errors'); + if (is_array($errors) && !empty($errors)) { + return collect($errors)->map(fn ($error) => data_get($error, 'description', data_get($error, 'message')))->filter()->implode('; '); + } + + return "MessageBird request failed with status code: {$statusCode}"; + } +} diff --git a/src/Services/SmppGatewayClient.php b/src/Services/SmppGatewayClient.php new file mode 100644 index 00000000..db1b4228 --- /dev/null +++ b/src/Services/SmppGatewayClient.php @@ -0,0 +1,136 @@ +config = $config; + } + + public function connect(): void + { + $scheme = data_get($this->config, 'tls') ? 'tls' : 'tcp'; + $host = data_get($this->config, 'host'); + $port = (int) data_get($this->config, 'port', 2775); + $timeout = (float) data_get($this->config, 'timeout', 10); + + $this->socket = @stream_socket_client("{$scheme}://{$host}:{$port}", $errno, $errstr, $timeout); + if (!$this->socket) { + throw new \RuntimeException("Unable to connect to SMPP gateway: {$errstr}", $errno); + } + + stream_set_timeout($this->socket, (int) $timeout); + $this->bind(); + } + + public function submit(string $from, string $to, string $text, array $options = []): string + { + $body = $this->cstring((string) data_get($options, 'service_type', '')) + . chr((int) data_get($options, 'source_addr_ton', data_get($this->config, 'source_addr_ton', 5))) + . chr((int) data_get($options, 'source_addr_npi', data_get($this->config, 'source_addr_npi', 0))) + . $this->cstring($from) + . chr((int) data_get($options, 'dest_addr_ton', data_get($this->config, 'dest_addr_ton', 1))) + . chr((int) data_get($options, 'dest_addr_npi', data_get($this->config, 'dest_addr_npi', 1))) + . $this->cstring(ltrim($to, '+')) + . chr(0) + . chr(0) + . chr(0) + . $this->cstring('') + . $this->cstring('') + . chr(1) + . chr(0) + . chr(0) + . chr((int) data_get($options, 'data_coding', data_get($this->config, 'data_coding', 0))) + . chr(0) + . chr(strlen($text)) + . $text; + + $response = $this->sendPdu(0x00000004, $body); + + return $this->readCString($response['body']) ?: (string) $response['sequence']; + } + + public function close(): void + { + if (!$this->socket) { + return; + } + + try { + $this->sendPdu(0x00000006, ''); + } catch (\Throwable $e) { + // Ignore disconnect errors while closing the transport. + } + + fclose($this->socket); + $this->socket = null; + } + + protected function bind(): void + { + $bindMode = (string) data_get($this->config, 'bind_type', 'transceiver'); + $command = match ($bindMode) { + 'transmitter' => 0x00000002, + 'receiver' => 0x00000001, + default => 0x00000009, + }; + + $body = $this->cstring((string) data_get($this->config, 'system_id')) + . $this->cstring((string) data_get($this->config, 'password', '')) + . $this->cstring((string) data_get($this->config, 'system_type', '')) + . chr((int) data_get($this->config, 'interface_version', 0x34)) + . chr((int) data_get($this->config, 'addr_ton', 0)) + . chr((int) data_get($this->config, 'addr_npi', 0)) + . $this->cstring((string) data_get($this->config, 'address_range', '')); + + $this->sendPdu($command, $body); + } + + protected function sendPdu(int $commandId, string $body): array + { + $sequence = $this->sequence++; + $length = 16 + strlen($body); + $packet = pack('NNNN', $length, $commandId, 0, $sequence) . $body; + + fwrite($this->socket, $packet); + + $header = fread($this->socket, 16); + if (strlen($header) !== 16) { + throw new \RuntimeException('Invalid SMPP response header'); + } + + $parts = unpack('Nlength/Ncommand/Nstatus/Nsequence', $header); + $bodyLength = max(0, $parts['length'] - 16); + $responseBody = $bodyLength > 0 ? fread($this->socket, $bodyLength) : ''; + + if ((int) $parts['status'] !== 0) { + throw new \RuntimeException('SMPP command failed with status: ' . $parts['status'], (int) $parts['status']); + } + + return [ + 'command' => (int) $parts['command'], + 'sequence' => (int) $parts['sequence'], + 'body' => $responseBody, + ]; + } + + protected function cstring(string $value): string + { + return $value . "\0"; + } + + protected function readCString(string $value): string + { + $position = strpos($value, "\0"); + + return $position === false ? $value : substr($value, 0, $position); + } +} diff --git a/src/Services/SmppSmsService.php b/src/Services/SmppSmsService.php new file mode 100644 index 00000000..882cd94a --- /dev/null +++ b/src/Services/SmppSmsService.php @@ -0,0 +1,82 @@ +config = $config ?? config('services.sms.providers.smpp', config('sms.providers.smpp', [])); + $this->clientFactory = $clientFactory; + } + + public function send(string $to, string $text, ?string $from = null, array $options = []): array + { + $from = $from ?: data_get($this->config, 'source_addr'); + $this->validateParameters($to, $text, $from); + + Log::info('Sending SMS via SMPP gateway', [ + 'to' => $to, + 'from' => $from, + ]); + + $client = $this->makeClient(); + $client->connect(); + + try { + $messageId = $client->submit($from, $to, $text, $options); + } finally { + $client->close(); + } + + return [ + 'success' => true, + 'message_id' => $messageId, + 'result' => 'SUCCESS', + 'status' => 'sent', + ]; + } + + public function isConfigured(): bool + { + return !empty(data_get($this->config, 'host')) + && !empty(data_get($this->config, 'port')) + && !empty(data_get($this->config, 'system_id')) + && data_get($this->config, 'password') !== null + && !empty(data_get($this->config, 'source_addr')); + } + + protected function validateParameters(string $to, string $text, ?string $from): void + { + if (!$this->isConfigured()) { + throw new \InvalidArgumentException('SMPP SMS gateway is not configured'); + } + + if (empty($to)) { + throw new \InvalidArgumentException('Recipient phone number (to) is required'); + } + + if (empty($text)) { + throw new \InvalidArgumentException('Message text cannot be empty'); + } + + if (empty($from)) { + throw new \InvalidArgumentException('SMPP source address is required'); + } + } + + protected function makeClient(): SmppGatewayClient + { + if (is_callable($this->clientFactory)) { + return call_user_func($this->clientFactory, $this->config); + } + + return new SmppGatewayClient($this->config); + } +} diff --git a/src/Services/SmsService.php b/src/Services/SmsService.php index 4e744218..4599acca 100644 --- a/src/Services/SmsService.php +++ b/src/Services/SmsService.php @@ -11,8 +11,13 @@ class SmsService /** * Available SMS providers. */ - public const PROVIDER_TWILIO = 'twilio'; - public const PROVIDER_CALLPRO = 'callpro'; + public const PROVIDER_TWILIO = 'twilio'; + public const PROVIDER_CALLPRO = 'callpro'; + public const PROVIDER_VONAGE = 'vonage'; + public const PROVIDER_MESSAGEBIRD = 'messagebird'; + public const PROVIDER_AWS_SNS = 'aws_sns'; + public const PROVIDER_SMPP = 'smpp'; + public const PROVIDER_CUSTOM_HTTP = 'custom_http'; /** * Default SMS provider. @@ -70,9 +75,14 @@ public function send(string $to, string $text, array $options = [], ?string $pro try { $result = match ($selectedProvider) { - self::PROVIDER_CALLPRO => $this->sendViaCallPro($normalizedPhone, $text, $options), - self::PROVIDER_TWILIO => $this->sendViaTwilio($normalizedPhone, $text, $options), - default => throw new \InvalidArgumentException("Unsupported SMS provider: {$selectedProvider}"), + self::PROVIDER_CALLPRO => $this->sendViaCallPro($normalizedPhone, $text, $options), + self::PROVIDER_TWILIO => $this->sendViaTwilio($normalizedPhone, $text, $options), + self::PROVIDER_VONAGE => $this->sendViaVonage($normalizedPhone, $text, $options), + self::PROVIDER_MESSAGEBIRD => $this->sendViaMessageBird($normalizedPhone, $text, $options), + self::PROVIDER_AWS_SNS => $this->sendViaAwsSns($normalizedPhone, $text, $options), + self::PROVIDER_SMPP => $this->sendViaSmpp($normalizedPhone, $text, $options), + self::PROVIDER_CUSTOM_HTTP => $this->sendViaCustomHttp($normalizedPhone, $text, $options), + default => throw new \InvalidArgumentException("Unsupported SMS provider: {$selectedProvider}"), }; $result['provider'] = $selectedProvider; @@ -176,6 +186,56 @@ protected function sendViaTwilio(string $to, string $text, array $options = []): } } + /** + * Send SMS via Vonage. + */ + protected function sendViaVonage(string $to, string $text, array $options = []): array + { + $service = new VonageSmsService(); + + return $service->send($to, $text, data_get($options, 'from'), $options); + } + + /** + * Send SMS via MessageBird. + */ + protected function sendViaMessageBird(string $to, string $text, array $options = []): array + { + $service = new MessageBirdSmsService(); + + return $service->send($to, $text, data_get($options, 'from', data_get($options, 'originator')), $options); + } + + /** + * Send SMS via AWS SNS. + */ + protected function sendViaAwsSns(string $to, string $text, array $options = []): array + { + $service = new AwsSnsSmsService(); + + return $service->send($to, $text, data_get($options, 'from', data_get($options, 'sender_id')), $options); + } + + /** + * Send SMS via SMPP gateway. + */ + protected function sendViaSmpp(string $to, string $text, array $options = []): array + { + $service = new SmppSmsService(); + + return $service->send($to, $text, data_get($options, 'from', data_get($options, 'source_addr')), $options); + } + + /** + * Send SMS via custom HTTP gateway. + */ + protected function sendViaCustomHttp(string $to, string $text, array $options = []): array + { + $service = new CustomHttpSmsService(); + + return $service->send($to, $text, data_get($options, 'from'), $options); + } + /** * Determine which provider to use based on phone number. * @@ -257,6 +317,31 @@ public function getAvailableProviders(): array 'available' => $callProService->isConfigured(), ]; + $providers[self::PROVIDER_VONAGE] = [ + 'name' => 'Vonage', + 'available' => (new VonageSmsService())->isConfigured(), + ]; + + $providers[self::PROVIDER_MESSAGEBIRD] = [ + 'name' => 'MessageBird', + 'available' => (new MessageBirdSmsService())->isConfigured(), + ]; + + $providers[self::PROVIDER_AWS_SNS] = [ + 'name' => 'AWS SNS', + 'available' => (new AwsSnsSmsService())->isConfigured(), + ]; + + $providers[self::PROVIDER_SMPP] = [ + 'name' => 'SMPP Gateway', + 'available' => (new SmppSmsService())->isConfigured(), + ]; + + $providers[self::PROVIDER_CUSTOM_HTTP] = [ + 'name' => 'Custom HTTP Gateway', + 'available' => (new CustomHttpSmsService())->isConfigured(), + ]; + return $providers; } diff --git a/src/Services/VonageSmsService.php b/src/Services/VonageSmsService.php new file mode 100644 index 00000000..63677695 --- /dev/null +++ b/src/Services/VonageSmsService.php @@ -0,0 +1,98 @@ +apiKey = (string) data_get($config, 'api_key', ''); + $this->apiSecret = (string) data_get($config, 'api_secret', ''); + $this->from = (string) data_get($config, 'from', ''); + $this->baseUrl = rtrim((string) data_get($config, 'base_url', 'https://rest.nexmo.com/sms/json'), '/'); + } + + public function send(string $to, string $text, ?string $from = null, array $options = []): array + { + $from = $from ?: $this->from; + $this->validateParameters($to, $text, $from); + + $payload = array_filter([ + 'api_key' => $this->apiKey, + 'api_secret' => $this->apiSecret, + 'from' => $from, + 'to' => $this->normalizeRecipient($to), + 'text' => $text, + 'type' => data_get($options, 'type'), + 'client-ref' => data_get($options, 'unique_id', data_get($options, 'client_ref')), + ], static fn ($value) => $value !== null && $value !== ''); + + Log::info('Sending SMS via Vonage', [ + 'to' => $to, + 'from' => $from, + ]); + + $response = Http::asForm()->post($this->baseUrl, $payload); + $body = $response->json(); + $message = is_array($body) ? data_get($body, 'messages.0', []) : []; + $status = (string) data_get($message, 'status', $response->successful() ? '0' : (string) $response->status()); + + if ($response->successful() && $status === '0') { + return [ + 'success' => true, + 'message_id' => data_get($message, 'message-id'), + 'result' => data_get($message, 'status', 'accepted'), + 'status' => 'accepted', + 'response' => $body, + ]; + } + + return [ + 'success' => false, + 'error' => data_get($message, 'error-text', "Vonage request failed with status code: {$response->status()}"), + 'code' => $status, + 'response' => $body, + ]; + } + + public function isConfigured(): bool + { + return !empty($this->apiKey) && !empty($this->apiSecret) && !empty($this->from); + } + + protected function validateParameters(string $to, string $text, string $from): void + { + if (!$this->isConfigured()) { + throw new \InvalidArgumentException('Vonage SMS provider is not configured'); + } + + if (empty($to)) { + throw new \InvalidArgumentException('Recipient phone number (to) is required'); + } + + if (empty($text)) { + throw new \InvalidArgumentException('Message text cannot be empty'); + } + + if (empty($from)) { + throw new \InvalidArgumentException('Vonage sender (from) is required'); + } + } + + protected function normalizeRecipient(string $to): string + { + return ltrim(preg_replace('/[^0-9+]/', '', $to), '+'); + } +} diff --git a/src/Support/EnvironmentMapper.php b/src/Support/EnvironmentMapper.php index 2cbdc580..c6378275 100644 --- a/src/Support/EnvironmentMapper.php +++ b/src/Support/EnvironmentMapper.php @@ -36,6 +36,24 @@ class EnvironmentMapper 'TWILIO_SID' => 'services.twilio.sid', 'TWILIO_TOKEN' => 'services.twilio.token', 'TWILIO_FROM' => 'services.twilio.from', + 'SMS_DEFAULT_PROVIDER' => 'sms.default_provider', + 'VONAGE_API_KEY' => 'services.sms.providers.vonage.api_key', + 'VONAGE_API_SECRET' => 'services.sms.providers.vonage.api_secret', + 'VONAGE_SMS_FROM' => 'services.sms.providers.vonage.from', + 'MESSAGEBIRD_ACCESS_KEY' => 'services.sms.providers.messagebird.access_key', + 'MESSAGEBIRD_ORIGINATOR' => 'services.sms.providers.messagebird.originator', + 'AWS_SNS_ACCESS_KEY_ID' => 'services.sms.providers.aws_sns.key', + 'AWS_SNS_SECRET_ACCESS_KEY' => 'services.sms.providers.aws_sns.secret', + 'AWS_SNS_REGION' => 'services.sms.providers.aws_sns.region', + 'AWS_SNS_SMS_SENDER_ID' => 'services.sms.providers.aws_sns.sender_id', + 'SMPP_HOST' => 'services.sms.providers.smpp.host', + 'SMPP_PORT' => 'services.sms.providers.smpp.port', + 'SMPP_SYSTEM_ID' => 'services.sms.providers.smpp.system_id', + 'SMPP_PASSWORD' => 'services.sms.providers.smpp.password', + 'SMPP_SOURCE_ADDR' => 'services.sms.providers.smpp.source_addr', + 'CUSTOM_HTTP_SMS_URL' => 'services.sms.providers.custom_http.url', + 'CUSTOM_HTTP_SMS_AUTH_HEADER' => 'services.sms.providers.custom_http.auth_header', + 'CUSTOM_HTTP_SMS_AUTH_TOKEN' => 'services.sms.providers.custom_http.auth_token', 'GOOGLE_MAPS_API_KEY' => 'services.google_maps.api_key', 'GOOGLE_MAPS_LOCALE' => 'services.google_maps.locale', 'GOOGLE_CLOUD_PROJECT_ID' => 'filesystem.gcs.project_id', @@ -95,6 +113,10 @@ class EnvironmentMapper ['settingsKey' => 'services.google_maps', 'configKey' => 'services.google_maps'], ['settingsKey' => 'services.twilio', 'configKey' => 'services.twilio'], ['settingsKey' => 'services.twilio', 'configKey' => 'twilio.twilio.connections.twilio'], + ['settingsKey' => 'services.sms', 'configKey' => 'services.sms'], + ['settingsKey' => 'services.sms.providers', 'configKey' => 'sms.providers'], + ['settingsKey' => 'sms.default_provider', 'configKey' => 'sms.default_provider'], + ['settingsKey' => 'sms.routing_rules', 'configKey' => 'sms.routing_rules'], ['settingsKey' => 'services.ipinfo', 'configKey' => 'services.ipinfo'], ['settingsKey' => 'services.ipinfo', 'configKey' => 'fleetbase.services.ipinfo'], ['settingsKey' => 'services.mailgun', 'configKey' => 'services.mailgun'], diff --git a/src/routes.php b/src/routes.php index 84e74c11..2ccb6c42 100644 --- a/src/routes.php +++ b/src/routes.php @@ -205,6 +205,7 @@ function ($router, $controller) { $router->post('test-queue-config', $controller('testQueueConfig')); $router->get('services-config', $controller('getServicesConfig')); $router->post('services-config', $controller('saveServicesConfig')); + $router->post('test-sms-provider-config', $controller('testSmsProviderConfig')); $router->post('test-twilio-config', $controller('testTwilioConfig')); $router->post('test-sentry-config', $controller('testSentryConfig')); $router->post('branding', $controller('saveBrandingSettings')); diff --git a/tests/Unit/MultiProviderSmsServiceTest.php b/tests/Unit/MultiProviderSmsServiceTest.php new file mode 100644 index 00000000..c05a938f --- /dev/null +++ b/tests/Unit/MultiProviderSmsServiceTest.php @@ -0,0 +1,268 @@ +instance('config', new Repository([ + 'services' => [ + 'aws' => [ + 'key' => 'aws-key', + 'secret' => 'aws-secret', + 'region' => 'us-east-1', + ], + 'sms' => [ + 'providers' => [ + 'vonage' => [ + 'api_key' => 'vonage-key', + 'api_secret' => 'vonage-secret', + 'from' => 'Fleetbase', + 'base_url' => 'https://rest.nexmo.com/sms/json', + ], + 'messagebird' => [ + 'access_key' => 'messagebird-key', + 'originator' => 'Fleetbase', + 'base_url' => 'https://rest.messagebird.com/messages', + ], + 'aws_sns' => [ + 'key' => 'aws-key', + 'secret' => 'aws-secret', + 'region' => 'us-east-1', + 'sender_id' => 'FLEETBASE', + 'sms_type' => 'Transactional', + ], + 'smpp' => [ + 'host' => 'smpp.example.test', + 'port' => 2775, + 'system_id' => 'fleetbase', + 'password' => 'secret', + 'source_addr' => 'FLEETBASE', + ], + 'custom_http' => [ + 'url' => 'https://sms-gateway.test/send', + 'from' => 'Fleetbase', + 'auth_header' => 'Authorization', + 'auth_token' => 'Bearer token', + 'headers' => [ + 'X-Tenant' => 'fleetbase', + ], + 'body' => [ + 'recipient' => '{{to}}', + 'message' => '{{text}}', + 'sender' => '{{from}}', + 'reference' => '{{unique_id}}', + ], + ], + ], + ], + ], + 'sms' => [ + 'default_provider' => SmsService::PROVIDER_VONAGE, + 'routing_rules' => [ + '+44' => SmsService::PROVIDER_MESSAGEBIRD, + ], + 'providers' => [], + ], + ])); + $app->instance('log', new NullLogger()); + $app->instance(Factory::class, new Factory()); + + Container::setInstance($app); + Facade::setFacadeApplication($app); + Facade::clearResolvedInstances(); +}); + +test('vonage sms service sends form payload and maps success response', function () { + Http::fake([ + 'https://rest.nexmo.com/sms/json' => Http::response([ + 'messages' => [ + [ + 'status' => '0', + 'message-id' => 'vonage-message-id', + ], + ], + ], 200), + ]); + + $result = (new VonageSmsService())->send('+15551234567', 'Hello'); + + expect($result)->toMatchArray([ + 'success' => true, + 'message_id' => 'vonage-message-id', + 'status' => 'accepted', + ]); + + Http::assertSent(function ($request) { + return $request->url() === 'https://rest.nexmo.com/sms/json' + && $request['api_key'] === 'vonage-key' + && $request['api_secret'] === 'vonage-secret' + && $request['from'] === 'Fleetbase' + && $request['to'] === '15551234567' + && $request['text'] === 'Hello'; + }); +}); + +test('messagebird sms service sends json payload and maps message id', function () { + Http::fake([ + 'https://rest.messagebird.com/messages' => Http::response([ + 'id' => 'messagebird-id', + 'recipients' => [ + 'items' => [ + ['status' => 'sent'], + ], + ], + ], 201), + ]); + + $result = (new MessageBirdSmsService())->send('+15551234567', 'Hello', null, [ + 'unique_id' => 'verification-123', + ]); + + expect($result)->toMatchArray([ + 'success' => true, + 'message_id' => 'messagebird-id', + 'status' => 'sent', + ]); + + Http::assertSent(function ($request) { + return $request->url() === 'https://rest.messagebird.com/messages' + && $request->hasHeader('Authorization', 'AccessKey messagebird-key') + && $request['originator'] === 'Fleetbase' + && $request['recipients'] === ['15551234567'] + && $request['body'] === 'Hello' + && $request['reference'] === 'verification-123'; + }); +}); + +test('custom http sms service renders configured templates', function () { + Http::fake([ + 'https://sms-gateway.test/send' => Http::response([ + 'message_id' => 'custom-message-id', + 'status' => 'queued', + ], 200), + ]); + + $result = (new CustomHttpSmsService())->send('+15551234567', 'Hello', null, [ + 'unique_id' => 'custom-123', + ]); + + expect($result)->toMatchArray([ + 'success' => true, + 'message_id' => 'custom-message-id', + 'status' => 'queued', + ]); + + Http::assertSent(function ($request) { + return $request->url() === 'https://sms-gateway.test/send' + && $request->hasHeader('Authorization', 'Bearer token') + && $request->hasHeader('X-Tenant', 'fleetbase') + && $request['recipient'] === '+15551234567' + && $request['message'] === 'Hello' + && $request['sender'] === 'Fleetbase' + && $request['reference'] === 'custom-123'; + }); +}); + +test('aws sns sms service publishes to phone number', function () { + $mock = new MockHandler(); + $mock->append(new Result(['MessageId' => 'sns-message-id'])); + $client = new SnsClient([ + 'version' => 'latest', + 'region' => 'us-east-1', + 'handler' => $mock, + 'credentials' => [ + 'key' => 'aws-key', + 'secret' => 'aws-secret', + ], + ]); + + $result = (new AwsSnsSmsService(null, $client))->send('+15551234567', 'Hello'); + + expect($result)->toMatchArray([ + 'success' => true, + 'message_id' => 'sns-message-id', + 'status' => 'sent', + ]); +}); + +test('smpp sms service validates config and delegates to client', function () { + $client = new class(config('services.sms.providers.smpp')) extends SmppGatewayClient { + public bool $connected = false; + + public bool $closed = false; + + public array $submitted = []; + + public function connect(): void + { + $this->connected = true; + } + + public function submit(string $from, string $to, string $text, array $options = []): string + { + $this->submitted = compact('from', 'to', 'text', 'options'); + + return 'smpp-message-id'; + } + + public function close(): void + { + $this->closed = true; + } + }; + + $service = new SmppSmsService(null, fn () => $client); + $result = $service->send('+15551234567', 'Hello'); + + expect($result)->toMatchArray([ + 'success' => true, + 'message_id' => 'smpp-message-id', + ]) + ->and($client->connected)->toBeTrue() + ->and($client->closed)->toBeTrue() + ->and($client->submitted['from'])->toBe('FLEETBASE') + ->and($client->submitted['to'])->toBe('+15551234567'); +}); + +test('sms service routes explicit provider and prefix rules to new providers', function () { + Http::fake([ + 'https://rest.messagebird.com/messages' => Http::response([ + 'id' => 'messagebird-id', + ], 201), + 'https://rest.nexmo.com/sms/json' => Http::response([ + 'messages' => [ + [ + 'status' => '0', + 'message-id' => 'vonage-id', + ], + ], + ], 200), + ]); + + $messageBirdResult = (new SmsService())->send('+441234567890', 'Hello'); + $vonageResult = (new SmsService())->send('+15551234567', 'Hello', [], SmsService::PROVIDER_VONAGE); + + expect($messageBirdResult)->toMatchArray([ + 'success' => true, + 'provider' => SmsService::PROVIDER_MESSAGEBIRD, + ])->and($vonageResult)->toMatchArray([ + 'success' => true, + 'provider' => SmsService::PROVIDER_VONAGE, + ]); +});