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",
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/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/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/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