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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions docs/integrations-guide/textbee.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ TextBee allows you to use your own Android device as an SMS gateway, providing u
3. Click **Generate API Key**
4. Copy the API key - you'll need this for MPM configuration
5. Note your **Device ID** from the dashboard or app
6. (Optional) To enable incoming SMS, create a **Webhook Subscription** for the `MESSAGE_RECEIVED` event and copy the **Webhook Secret**

## Step 4: Enable TextBee Plugin in MPM

Expand All @@ -51,9 +52,21 @@ TextBee allows you to use your own Android device as an SMS gateway, providing u
3. Enter the following credentials:
- **API Key**: Paste the API key from Step 3
- **Device ID**: Enter your device ID from Step 3
- **Webhook Secret** (optional): Paste the webhook secret from Step 3 to enable incoming SMS
4. Click **Save** to store the configuration
5. The plugin will validate your credentials automatically

### Incoming SMS (Optional)

To receive incoming SMS from customers, you need to configure a webhook in the TextBee dashboard:

1. In the TextBee dashboard, navigate to **Webhooks**
2. Create a new webhook subscription for the `MESSAGE_RECEIVED` event
3. Set the webhook URL to: `https://your-mpm-domain/api/textbee-sms-gateway/callback/{company-id}/incoming-messages`
- Replace `{company-id}` with your MPM company ID
4. Copy the webhook secret and paste it in the MPM TextBee configuration
5. Incoming SMS will now be stored and processed by MPM

## Step 6: Select TextBee as Your SMS Gateway

1. Navigate to **Settings** → **Configuration** → **Main Settings**
Expand Down
1 change: 1 addition & 0 deletions docs/usage-guide/sms.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ TextBee allows you to use your own Android device as an SMS gateway, providing u

- Up to 98% cost savings
- Uses your own Android device
- Bidirectional SMS (send and receive)
- Free plan: 300 messages/month
- Pro plan: 5,000 messages/month

Expand Down
7 changes: 5 additions & 2 deletions src/backend/app/Events/SmsStoredEvent.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Events;

use App\Models\Sms;
use Illuminate\Foundation\Events\Dispatchable;
use Illuminate\Queue\SerializesModels;

Expand All @@ -11,8 +12,9 @@
* Dispatch this event to asynchronously store an SMS.
* A corresponding listener will send the event.
*
* @property string $sender The sender of the SMS.
* @property string $message The message content of the SMS.
* @property string $sender The sender of the SMS.
* @property string $message The message content of the SMS.
* @property Sms|null $sms The SMS model instance, if available.
*/
class SmsStoredEvent {
use Dispatchable;
Expand All @@ -21,5 +23,6 @@ class SmsStoredEvent {
public function __construct(
public string $sender,
public string $message,
public ?Sms $sms = null,
) {}
}
2 changes: 1 addition & 1 deletion src/backend/app/Http/Controllers/SmsController.php
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ public function store(StoreSmsRequest $request): ApiResource {
$sms = $this->smsService->createSms($smsData);

match ($this->smsService->checkMessageType($message)) {
$this->smsService::FEEDBACK => event(new SmsStoredEvent($sender, $message)),
$this->smsService::FEEDBACK => event(new SmsStoredEvent($sender, $message, $sms)),
$this->smsService::TICKET => $this->commentService->storeComment($sender, $message),
default => new ApiResource($sms),
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,15 +29,15 @@ public function incoming(Request $request): JsonResponse {
$senderId = $sender ? $sender->id : null;

$smsData = [
'receiver' => $phoneNumber,
'receiver' => $address->phone ?? $phoneNumber,
'body' => $message,
'sender_id' => $senderId,
'direction' => Sms::DIRECTION_INCOMING,
'status' => Sms::STATUS_DELIVERED,
];

$this->smsService->createSms($smsData);
event(new SmsStoredEvent($phoneNumber, $message));
$sms = $this->smsService->createSms($smsData);
event(new SmsStoredEvent($phoneNumber, $message, $sms));

return response()->json(['status' => 'success']);
}
Expand Down
25 changes: 25 additions & 0 deletions src/backend/app/Plugins/AfricasTalking/Listeners/SmsListener.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Plugins\AfricasTalking\Listeners;

use App\Events\SmsStoredEvent;
use App\Listeners\SmsListener as GlobalSmsListener;
use App\Models\MpmPlugin;
use App\Services\MainSettingsService;

class SmsListener {
public function __construct(
private GlobalSmsListener $globalSmsListener,
private MainSettingsService $mainSettingsService,
) {}

public function handle(SmsStoredEvent $event): void {
$mainSettings = $this->mainSettingsService->getAll()->first();

if ($mainSettings?->sms_gateway_id !== MpmPlugin::AFRICAS_TALKING) {
return;
}

$this->globalSmsListener->handle($event);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@

namespace App\Plugins\AfricasTalking\Providers;

use App\Events\SmsStoredEvent;
use App\Plugins\AfricasTalking\Listeners\SmsListener;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider {
protected $listen = [
SmsStoredEvent::class => [SmsListener::class],
];

/**
* Register any events for your application.
*/
Expand Down
7 changes: 3 additions & 4 deletions src/backend/app/Plugins/SparkMeter/Listeners/SmsListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace App\Plugins\SparkMeter\Listeners;

use App\Models\Meter\Meter;
use App\Events\SmsStoredEvent;
use App\Plugins\SparkMeter\Exceptions\SparkAPIResponseException;
use App\Plugins\SparkMeter\Models\SmCustomer;
use App\Plugins\SparkMeter\Services\CustomerService;
Expand Down Expand Up @@ -56,8 +56,7 @@ public function onSmsStored(string $sender, string $message): void {
}
}

public function handle(string $sender, string $message): void {
// TODO: Uncomment this when spark-meter package is refactored with device->meter approach
// $this->onSmsStored($sender, $message);
public function handle(SmsStoredEvent $event): void {
$this->onSmsStored($event->sender, $event->message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -529,7 +529,7 @@ static function ($q) use ($phoneNumber) {

return $this->smCustomer->newQuery()->with(['site', 'mpmPerson.devices.device'])->where(
'mpm_customer_id',
$person->id
$person?->id
)->first();
}
}
6 changes: 3 additions & 3 deletions src/backend/app/Plugins/SteamaMeter/Listeners/SmsListener.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

namespace App\Plugins\SteamaMeter\Listeners;

use App\Events\SmsStoredEvent;
use App\Plugins\SteamaMeter\Models\SteamaCustomer;
use App\Plugins\SteamaMeter\Services\SteamaCustomerService;
use App\Plugins\SteamaMeter\Services\SteamaSmsFeedbackWordService;
Expand Down Expand Up @@ -35,8 +36,7 @@ public function onSmsStored(string $sender, string $message): void {
}
}

public function handle(string $sender, string $message): void {
// TODO: Uncomment this when steamaco-meter package is refactored with device->meter approach
// $this->onSmsStored($sender, $message);
public function handle(SmsStoredEvent $event): void {
$this->onSmsStored($event->sender, $event->message);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -506,6 +506,6 @@ static function ($q) use ($phoneNumber) {

return $this->customer->newQuery()
->with(['site', 'mpmPerson.devices.device'])
->where('mpm_customer_id', $person->id)->first();
->where('mpm_customer_id', $person?->id)->first();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
<?php

namespace App\Plugins\TextbeeSmsGateway\Http\Controllers;

use App\Events\SmsStoredEvent;
use App\Models\Address\Address;
use App\Models\Sms;
use App\Plugins\TextbeeSmsGateway\Services\TextbeeCredentialService;
use App\Services\AddressesService;
use App\Services\SmsService;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Routing\Controller;

class TextbeeCallbackController extends Controller {
public function __construct(
private TextbeeCredentialService $credentialService,
private SmsService $smsService,
private AddressesService $addressesService,
) {}

public function incoming(Request $request, string $slug): JsonResponse {
$credentials = $this->credentialService->getCredentials();

if ($credentials?->webhook_secret) {
$signature = $request->header('x-signature');
$expectedSignature = hash_hmac('sha256', $request->getContent(), $credentials->webhook_secret);

if (!$signature || !hash_equals($expectedSignature, $signature)) {
return response()->json(['status' => 'unauthorized'], 401);
}
}

$data = $request->all();
$phoneNumber = $data['sender'];
$message = $data['message'];
$address = $this->addressesService->getAddressByPhoneNumber(str_replace(' ', '', $phoneNumber));
$sender = $address instanceof Address ? $address->owner : null;
// @phpstan-ignore property.notFound
$senderId = $sender ? $sender->id : null;

$smsData = [
'receiver' => $address->phone ?? $phoneNumber,
'body' => $message,
'sender_id' => $senderId,
'direction' => Sms::DIRECTION_INCOMING,
'status' => Sms::STATUS_DELIVERED,
];

$sms = $this->smsService->createSms($smsData);
event(new SmsStoredEvent($phoneNumber, $message, $sms));

return response()->json(['status' => 'success']);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,12 +19,14 @@ public function show(): TextbeeResource {
public function update(TextbeeCredentialRequest $request): TextbeeResource {
$apiKey = $request->input('api_key');
$deviceId = $request->input('device_id');
$webhookSecret = $request->input('webhook_secret');
$id = $request->input('id');

$credentials = $this->credentialService->updateCredentials([
'id' => $id,
'api_key' => $apiKey,
'device_id' => $deviceId,
'webhook_secret' => $webhookSecret,
]);

return TextbeeResource::make($credentials);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace App\Plugins\TextbeeSmsGateway\Listeners;

use App\Events\SmsStoredEvent;
use App\Listeners\SmsListener as GlobalSmsListener;
use App\Models\MpmPlugin;
use App\Services\MainSettingsService;

class SmsListener {
public function __construct(
private GlobalSmsListener $globalSmsListener,
private MainSettingsService $mainSettingsService,
) {}

public function handle(SmsStoredEvent $event): void {
$mainSettings = $this->mainSettingsService->getAll()->first();

if ($mainSettings?->sms_gateway_id !== MpmPlugin::TEXTBEE_SMS_GATEWAY) {
return;
}

$this->globalSmsListener->handle($event);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
* @property int $id
* @property string|null $api_key
* @property string|null $device_id
* @property string|null $webhook_secret
* @property Carbon|null $created_at
* @property Carbon|null $updated_at
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@

namespace App\Plugins\TextbeeSmsGateway\Providers;

use App\Events\SmsStoredEvent;
use App\Plugins\TextbeeSmsGateway\Listeners\SmsListener;
use Illuminate\Foundation\Support\Providers\EventServiceProvider as ServiceProvider;

class EventServiceProvider extends ServiceProvider {
protected $listen = [
SmsStoredEvent::class => [SmsListener::class],
];

/**
* Register any events for your application.
*/
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,14 @@ public function createCredentials(): TextbeeCredential {
return $this->credential->newQuery()->firstOrCreate(['id' => 1], [
'api_key' => null,
'device_id' => null,
'webhook_secret' => null,
]);
}

public function getCredentials(): ?TextbeeCredential {
$credential = $this->credential->newQuery()->first();

return $this->decryptCredentialFields($credential, ['api_key', 'device_id']);
return $this->decryptCredentialFields($credential, ['api_key', 'device_id', 'webhook_secret']);
}

/**
Expand All @@ -34,13 +35,13 @@ public function getCredentials(): ?TextbeeCredential {
public function updateCredentials(array $data): TextbeeCredential {
$credential = $this->credential->newQuery()->find($data['id']);

$encryptedData = $this->encryptCredentialFields($data, ['api_key', 'device_id']);
$encryptedData = $this->encryptCredentialFields($data, ['api_key', 'device_id', 'webhook_secret']);

$credential->update($encryptedData);
$credential->save();

$credential->fresh();

return $this->decryptCredentialFields($credential, ['api_key', 'device_id']);
return $this->decryptCredentialFields($credential, ['api_key', 'device_id', 'webhook_secret']);
}
}
3 changes: 3 additions & 0 deletions src/backend/app/Plugins/TextbeeSmsGateway/routes/api.php
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,7 @@
Route::get('/', 'TextbeeCredentialController@show');
Route::put('/', 'TextbeeCredentialController@update');
});
Route::group(['prefix' => 'callback'], function () {
Route::post('/{slug}/incoming-messages', 'TextbeeCallbackController@incoming');
});
});
4 changes: 4 additions & 0 deletions src/backend/app/Services/ApiResolvers/Data/ApiResolverMap.php
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
use App\Services\ApiResolvers\PaystackApiResolver;
use App\Services\ApiResolvers\SwiftaPaymentApiResolver;
use App\Services\ApiResolvers\TestApiResolver;
use App\Services\ApiResolvers\TextbeeSmsGatewayApiResolver;
use App\Services\ApiResolvers\ViberMessagingApiResolver;
use App\Services\ApiResolvers\VodacomMobileMoneyApiResolver;
use App\Services\ApiResolvers\WaveMoneyApiResolver;
Expand All @@ -30,6 +31,7 @@ class ApiResolverMap {
public const VODACOM_MOBILE_MONEY = 'api/vodacom/';
public const PAYSTACK_API = 'api/paystack/';
public const ECREEE_METER_DATA_API = 'api/ecreee-e-tender/ecreee-meter-data';
public const TEXTBEE_SMS_GATEWAY_API = 'api/textbee-sms-gateway/callback';

public const RESOLVABLE_APIS = [
self::TEST_API,
Expand All @@ -44,6 +46,7 @@ class ApiResolverMap {
self::ODYSSEY_PAYMENTS_API,
self::PAYSTACK_API,
self::ECREEE_METER_DATA_API,
self::TEXTBEE_SMS_GATEWAY_API,
];

private const API_RESOLVER = [
Expand All @@ -59,6 +62,7 @@ class ApiResolverMap {
self::ODYSSEY_PAYMENTS_API => OdysseyPaymentApiResolver::class,
self::PAYSTACK_API => PaystackApiResolver::class,
self::ECREEE_METER_DATA_API => EcreeeMeterDataApiResolver::class,
self::TEXTBEE_SMS_GATEWAY_API => TextbeeSmsGatewayApiResolver::class,
];

/**
Expand Down
Loading
Loading