# Writing an SMS Module

https://dev.wisecp.com/es/writing-an-sms-module

An SMS module hands a text message to a gateway. Unlike mail it does three jobs: notifications, international sending and, sometimes, price and delivery reporting.

## Overview

There is no base class. The contract is duck typed like Mail: core loads the class named after the module directory and calls a known set of methods.

Two config keys point at drivers: `modules/sms` for notifications, `modules/sms-intl` for international traffic. A domestic gateway passes foreign numbers to the second, so every driver keeps two buckets.

A third path is module local: your `controllers/` files may build the driver with unsaved credentials.

## Prerequisites

- Write access to `coremio/modules/SMS/`.
- Gateway credentials and usually a registered sender ID.
- Is your gateway domestic only, or international? That drives the capability flags.
- [Writing a Mail Module](https://dev.wisecp.com/en/writing-a-mail-module).

## Structure

Directory, file and class name are the same string, no namespace.

```bash
coremio/modules/SMS/Acme/
├── Acme.php          the driver class, named Acme, no namespace
├── config.php        meta (with the international flag) + saved credentials
├── Source/class.php  optional gateway client, included by the constructor
├── lang/en.php
├── lang/tr.php
└── logo.png          optional, referenced from config meta.logo
```

## Walkthrough

### Building the Driver Class

1. Create `coremio/modules/SMS/Acme/Acme.php` with the guard and `class Acme`.
2. Declare the capability flags as public properties.
3. In the constructor, merge the saved config with the passed array, which wins.
4. Implement `body()`, `title()`, `AddNumber()`, each returning `$this`.
5. Implement `submit()`: send the domestic bucket, pass the international one on.
6. Implement `getTitle()`, `getBody()`, `getNumbers()`, `getError()`.

### Sender ID and Numbers

1. Seed the sender from config in the constructor.
2. In `AddNumber()`, accept three shapes: a number, a number plus country code, an array of either.
3. A value with a pipe is `countryCode|number`.
4. Route into a bucket by country code, unless the caller forbids the handoff.
5. Reset both buckets inside `body()`.

### Settings and Reports

1. Add `page_settings()` with the hidden fields, enable checkbox and credentials.
2. Add `controller_save()`: write changed values into `config.php`, flip `modules/sms`.
3. Implement `getBalance()` for a credit balance; core never calls it.
4. Implement `getReportID()` and `getReport()` for a batch id.
5. Implement `get_prices()` only for an international gateway. It feeds the country price table and the pricing cron.

## Reference

### What Core Calls

The dispatcher composes the message, then walks the recipients.

| Method | When core calls it | Must return |
| --- | --- | --- |
| `__construct($external_config = [])` | Once per dispatch | nothing |
| `body($text, $template, $variables, $lang, $user)` | First; resets buckets | `$this` |
| `title($arg)` | On a sender ID override | `$this` |
| `AddNumber($arg, $cc)` | Per recipient, or with an array | `$this` |
| `submit($isthis = false)` | After the recipients are in | truthy, or false on failure |
| `getTitle()` | After a good submit | string |
| `getBody()` | After a good submit | string |
| `getNumbers()` | After a good submit | both buckets, one array |
| `getError()` | After a falsy submit | the failure text |
| `numbers_reset()` | By your `body()` | not read |
| `getReportID()`, `getReport($id)`, `get_prices()` | Where offered | see below |
| `getBalance()` | Never by core | your own shape |

Note the casing: the dispatcher writes `addNumber()`, drivers declare `AddNumber()`. PHP resolves both.

### Method Signatures

```php
public function __construct($external_config = []);

// Resets BOTH recipient buckets, then renders the template when one is given.
// $template is "group/name", e.g. "user/gsm-activation"; false means $text is final.
public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0);

// The sender ID / originator shown on the handset.
public function title($arg = '');

// ORDER TRAP: the number is FIRST, the country code is SECOND.
// $arg accepts "5551234567", "90|5551234567", or an array of either shape.
// $cc is only consulted when $arg is not an array.
public function AddNumber($arg = 0, $cc = null);

// $isthis = true returns the driver instead of the boolean.
public function submit($isthis = false);

public function getTitle();
public function getBody();
public function getNumbers();
public function getError();
public function numbers_reset();

// Optional, feature by feature. Core probes each with method_exists() before calling.
public function getReportID();                // the batch id produced by the last submit
public function getReport($id = 0);           // delivery report for a batch
public function get_prices();                 // international price list, see below

// Module-local convention: core never calls this. It is reached only from your own
// controllers/*.php and pages/*.php, so the argument list is yours.
public function getBalance();                 // remaining credit, or false with $error set
```

Two return shapes are read by name:

```php
// getReport(): three named buckets, each with the raw rows and a count.
// The report reader also accepts 'delivered' / 'sending' / 'failed' as aliases.
return [
    'waiting'   => ['data' => $waitingRows,   'count' => count($waitingRows)],
    'conducted' => ['data' => $deliveredRows, 'count' => count($deliveredRows)],
    'erroneous' => ['data' => $failedRows,    'count' => count($failedRows)],
];

// get_prices(): country code => currency code => cost per message.
// The first usable currency wins unless config's supported-currencies names one.
return [
    'TR' => ['EUR' => 0.0180],
    'DE' => ['EUR' => 0.0640, 'USD' => 0.0700],
    'US' => ['USD' => 0.0075],
];
```

Costs from `get_prices()` are exchanged into the primary currency, the `sms/profit-rate` margin is added, and the country map is rewritten.

### Flags

Public properties, not methods. Core reads them on the instance and in the config, so both must agree.

- **$international**: True when the gateway delivers abroad.
- **$prevent_transmission_to_intl**: Keeps foreign numbers domestic. Honour it in `AddNumber()` and `submit()`.
- **$otp**: Marks a one time password batch, which some gateways route faster. Raise it from `body()`.
- **$error**: Read directly even though `getError()` exists.
- **meta.international**: The same answer inside `config.php`, used by the international driver picker. Property and meta must agree.

### Config Keys

- **meta.name**: Display name; the language file's `name` key wins.
- **meta.poweredby**: The gateway brand, as attribution.
- **origin**: The registered sender ID; every driver seeds `title()` from it.
- **supported-currencies**: Currency codes the importer prefers when `get_prices()` offers several. Empty means the first.
- **Crypt::encode()**: Encrypted: `Crypt::encode($v, Config::get("crypt/user"))` on save, `Crypt::decode()` on read.

### Which Driver Runs

Getting the third wrong sends with the operator's account, not the customer's.

| Path | How the instance is built | Credentials used |
| --- | --- | --- |
| Notifications | `Modules::Load("SMS")`, then `new $smsModule()` | saved config |
| International sending | `Modules::getInstance("SMS", Config::get("modules/sms-intl"))` | saved config |
| Your own module pages | `new YourModule($external)` | posted values over saved config |

That path is why the constructor takes `$external_config`; core never passes it. The factory's third argument is a positional argument list, not an options array.

## Example

A gateway that covers the home country and hands foreign numbers on.

"Home country" is the country your gateway sells in; read it from config. Strip a leading `+` before comparing, and mind the operator: `!=` treats `'90'` and `'+90'` as equal, `!==` does not.

```php
<?php
defined('CORE_FOLDER') OR exit('You can not get in here!');

class Acme
{
    public $international = false;
    public $prevent_transmission_to_intl = false;
    public $otp = false;
    public $error = null;
    public $lang = [];
    public $config = [];

    private $title = '';
    private $body = '';
    private $numbers = [];
    private $numbers_intl = [];

    public function __construct($external_config = [])
    {
        $this->lang   = Modules::Lang('SMS', __CLASS__);
        $this->config = array_merge(Modules::Config('SMS', __CLASS__) ?: [], $external_config);
        $this->title  = (string) ($this->config['origin'] ?? '');
    }

    public function title($arg = '')
    {
        $this->title = (string) $arg;
        return $this;
    }

    public function body($text = '', $template = false, $variables = [], $lang = '', $user = 0)
    {
        // The dispatcher reuses one instance per recipient; without this the buckets grow.
        $this->numbers_reset();

        if ($template) {
            if ($template === "user/gsm-activation" && ($this->config['otp'] ?? false)) $this->otp = true;

            $look = View::notifications('sms', $template, $text, $variables, $lang, $user);
            if ($look !== false && isset($look['content'])) {
                if (isset($look['title'])) $this->title($look['title']);
                $text = $look['content'];
            }
        }

        $this->body = (string) $text;
        return $this;
    }

    public function AddNumber($arg = 0, $cc = null)
    {
        if (!is_array($arg)) $arg = $cc ? [$cc . '|' . $arg] : [$arg];

        foreach ($arg as $num) {
            if (!str_contains((string) $num, '|')) {
                $this->numbers[] = Filter::numbers((string) $num);
                continue;
            }

            // Your gateway's own country, from its config — never a literal.
            $home = ltrim((string) ($this->config['home_cc'] ?? ''), '+');

            [$ccPart, $numPart] = explode('|', (string) $num, 2);
            $ccPart = ltrim($ccPart, '+') ?: $home;
            $full   = $ccPart . Filter::numbers($numPart);

            if (!$this->prevent_transmission_to_intl && $home !== '' && $ccPart !== $home) $this->numbers_intl[] = $full;
            else $this->numbers[] = $full;
        }

        return $this;
    }

    public function getTitle()
    {
        return $this->title;
    }

    public function getBody()
    {
        return $this->body;
    }

    public function getNumbers()
    {
        return array_merge($this->numbers, $this->numbers_intl);
    }

    public function getError()
    {
        return $this->error;
    }

    public function numbers_reset()
    {
        $this->otp          = false;
        $this->numbers      = [];
        $this->numbers_intl = [];
        return true;
    }

    public function submit($isthis = false)
    {
        if (Validation::isEmpty($this->body)) {
            $this->error = 'Message content can not be left blank!';
            return false;
        }

        if (!$this->numbers && !$this->numbers_intl) {
            $this->error = 'Enter the phone number to be sent.';
            return false;
        }

        $send = false;

        // Foreign numbers go to whichever driver holds modules/sms-intl.
        if (!$this->prevent_transmission_to_intl && $this->numbers_intl) {
            $intl = (string) Config::get("modules/sms-intl");
            if ($intl !== '' && $intl !== 'none') {
                $peer = Modules::getInstance('SMS', $intl);
                if ($peer) {
                    $send = $peer->body($this->getBody())->AddNumber($this->numbers_intl)->submit();

                    // No domestic numbers left, so the peer's outcome is the whole outcome.
                    if (!$this->numbers) {
                        $this->error = $peer->getError();
                        return $isthis ? $this : $send;
                    }
                }
            }
        }

        if ($this->numbers) {
            $response = Utility::HttpRequest([
                'url'  => 'https://api.example.com/sms/send',
                'type' => 'POST',
                'data' => [
                    'user'    => $this->config['username'] ?? '',
                    'pass'    => Crypt::decode($this->config['password'] ?? '', Config::get("crypt/user")),
                    'header'  => $this->title,
                    'message' => $this->body,
                    'to'      => implode(',', $this->numbers),
                    'otp'     => $this->otp ? 1 : 0,
                ],
            ]);

            $decoded = Utility::jdecode((string) $response, true) ?: [];
            $send    = (string) ($decoded['code'] ?? '') === '00';

            // The dispatcher reads the error after a falsy return; it does not catch throws.
            if (!$send) $this->error = $decoded['message'] ?? 'Acme refused the batch.';
        }

        return $isthis ? $this : $send;
    }
}
```

```php
Modules::Load("SMS");
$smsModule = Config::get("modules/sms");
$sms = $smsModule && $smsModule !== 'none' ? new $smsModule() : false;

// $phone arrives as "number|countryCode|lang", so the pieces are unpacked by position.
foreach ($adminContacts['phones'] as $phone) {
    $parse   = explode("|", (string) $phone);
    $aLang   = $parse[2] ?? $localLang;
    $sendSms = $sms->body($body, $templatePath, $variables, $aLang);

    if (isset($parse[1])) $sendSms->addNumber($parse[0], $parse[1]);
    else $sendSms->addNumber($parse[0]);

    $sendSms = $sendSms->submit();

    if ($sendSms) LogManager::Sms_Log(0, $reason, $sms->getTitle(), $sms->getBody(), implode(",", $sms->getNumbers()));
    else $errors['sms'][$phone] = $sms->getError();
}
```

## Pitfalls

> **AddNumber: number first, country code second**
> 
> The joined form is the other way round, `countryCode|number`, and a stored contact unpacks as `number|countryCode|lang`. A swap produces a plausible number that fails.

> **body() resets the buckets, and it must**
> 
> Mail resets recipients inside `subject()`; SMS resets inside `body()`. Without it, recipient two gets a batch still holding recipient one.

> **The flag is checked before money moves**
> 
> The client panel prices the batch, refuses unless the driver's `$international` property is true, then debits the balance. Claiming support only in `config.php` fails the send.

> **submit() reports failure by returning false**
> 
> The notification loop has no try/catch and reads `getError()` after a falsy return. Settings controllers may throw.

> **Test with the sandbox driver**
> 
> SampleSMS accepts everything and writes each message to `temp/sample-sms/`.

## Related Articles

- [Writing a Mail Module](https://dev.wisecp.com/en/writing-a-mail-module)
- [Writing an SMS Template](https://dev.wisecp.com/en/writing-an-sms-template)
- [Module Configuration](https://dev.wisecp.com/en/module-configuration)
- [Module Anatomy](https://dev.wisecp.com/en/module-anatomy)
- [Adding a Scheduled Task](https://dev.wisecp.com/en/adding-a-scheduled-task)
- [Filtering User Input](https://dev.wisecp.com/en/filtering-user-input)
