Writing an SMS Module

11 vues Markdown

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.

Structure

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

layout
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.

MethodWhen core calls itMust return
__construct($external_config = [])Once per dispatchnothing
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 intruthy, or false on failure
getTitle()After a good submitstring
getBody()After a good submitstring
getNumbers()After a good submitboth buckets, one array
getError()After a falsy submitthe failure text
numbers_reset()By your body()not read
getReportID(), getReport($id), get_prices()Where offeredsee below
getBalance()Never by coreyour own shape

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

Method Signatures

signatures
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:

return shapes
// 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.

PathHow the instance is builtCredentials used
NotificationsModules::Load("SMS"), then new $smsModule()saved config
International sendingModules::getInstance("SMS", Config::get("modules/sms-intl"))saved config
Your own module pagesnew 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.

Acme.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;
    }
}
coremio/helpers/notification.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/.

Cet article vous a-t-il été utile ?

Merci pour votre retour !

Besoin d'aide supplémentaire ?

Notre équipe d'assistance est disponible 24h/24 pour tout ce que vous ne trouvez pas ci-dessus.