Свой личный SMS-шлюз. Часть 2 – создаём API и форму отправки

в 8:59, , рубрики: Блог компании RUVDS.com

Свой личный SMS-шлюз. Часть 2 – создаём API и форму отправки - 1


Представляю вам вторую часть из серии статей по созданию своего шлюза.
В первой части мы настроили Gammu, рассмотрели особо интересные параметры и успешно произвели отправку SMS сообщения. Сейчас нам предстоит задача посложнее – создать некую программную прослойку (API), для того, чтобы можно было работать со шлюзом путем отправки запросов на этот API. В первую очередь это комфорт, во вторую – большое количество дополнительных возможностей.

Если вы не знакомы с первой частью, советую сначала ознакомиться с ней:
Свой личный SMS-шлюз. Часть 1 – цели, задачи, сборка и тестирование

Постановка задачи

Первое, что нужно для себя понять, какими свойствами и возможностями должен обладать наш код, что мы хотим от него получить и как с ним взаимодействовать. Для этого я поставил для себя пару вопросов и постарался максимально на них ответить.

На чем будем писать backend
Тут все просто, что умеем, на том и пишем, поэтому в моем случае – PHP

Авторизация
Конечно. Сервис будет смотреть в интернет, поэтому авторизация обязательна.

Один пользователь – одна sim-карта?
Конечно нет. У нас сервис для личного пользования и мы хотим иметь один логин, но при этом отправлять с нескольких номеров. Но если появится необходимость выделить один шлюз под конкретный сервис, мы должны иметь возможность добавления пользователей.

Как мы хотим общаться с этим API, откуда будут попадать запросы
Общение будет через POST/GET. Запросы могут отправляться различными устройствами, в том числе и теми, которые не умеют POST или заморочно реализовать, поэтому принимать и обрабатывать будем $_REQUEST. Также мы хотим иметь возможность отправки сообщений через простую форму на сайте.

Один запрос – один адресат?
Нет. В одном запросе с одним текстовым сообщением должна быть возможность указать несколько адресатов. Суть этого понятна. Например я отслеживаю наличие ЭЭ на даче и в случае отключения хочу получить уведомление на все свои телефоны, а может даже телефон супруги… почему бы и нет, ведь уведомление важное.

История отправленных сообщений
Конечно, история это наша важная составляющая жизни, поэтому ее мы всегда храним

Балансировка нагрузки на карты
Да. Мы обладаем чувством меры и будем отправлять с одной карты не более какого-то числа сообщений, а значит их нужно считать и перед выбором шлюза проверять на исчерпание лимита.

Задача на разработку поставлена, цель ясна, итак приступим

Первое что мы сделаем, определим структуру база данных. Без нее, при наших потребностях никак. Использовать будем MySQL.
Дальше нужно будет написать пару классов, к которым мы были обращаться.

Приступим к созданию БД и создание таблиц
Я буду использовать несколько таблиц для:

  • users – данные пользователей
  • smsc_gateway – данных шлюзов
  • gateway_smscount – счётчик отправленных сообщений по каждому шлюза в конкретный месяц
  • sms_queue – очередь отправки сообщений
  • sms_archive – история сообщений

Структура БД – дамп таблиц
# Дамп таблицы users
# ------------------------------------------------------------

CREATE TABLE `users` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `uuid` varchar(10) NOT NULL,
  `login` varchar(50) NOT NULL,
  `password` varchar(32) NOT NULL,
  `comment` varchar(200) NOT NULL,
  `status` varchar(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

# Дамп таблицы smsc_gateway
# ------------------------------------------------------------

CREATE TABLE `smsc_gateway` (
  `id` int(20) NOT NULL AUTO_INCREMENT,
  `gw_phone` varchar(11) NOT NULL DEFAULT '',
  `uuid` varchar(10) NOT NULL,
  `host` varchar(15) NOT NULL DEFAULT '',
  `port` varchar(5) NOT NULL,
  `password` varchar(12) DEFAULT '',
  `maxcount` varchar(6) NOT NULL,
  `status` int(1) NOT NULL,
  `gateway_id` int(2) DEFAULT NULL,
  `state` varchar(11) DEFAULT NULL,
  `comment` varchar(100) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

# Дамп таблицы gateway_smscount
# ------------------------------------------------------------

CREATE TABLE `gateway_smscount` (
  `id` int(20) NOT NULL AUTO_INCREMENT,
  `gw_phone` varchar(11) NOT NULL DEFAULT '',
  `date` varchar(10) NOT NULL,
  `count` int(6) NOT NULL DEFAULT '0',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

# Дамп таблицы sms_queue
# ------------------------------------------------------------

CREATE TABLE `sms_queue` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `uuid` varchar(11) NOT NULL DEFAULT '',
  `dateTime` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
  `status` varchar(10) DEFAULT NULL,
  `data` varchar(500) NOT NULL DEFAULT '',
  `phone` varchar(11) NOT NULL DEFAULT '',
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
# Дамп таблицы sms_archive
# ------------------------------------------------------------

CREATE TABLE `sms_archive` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `gateway_id` varchar(11) DEFAULT NULL,
  `dateTime` timestamp NULL DEFAULT CURRENT_TIMESTAMP,
  `data` varchar(500) DEFAULT NULL,
  `status` varchar(11) DEFAULT NULL,
  `phone` varchar(11) DEFAULT NULL,
  `uuid` varchar(11) DEFAULT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;

Стоит подробнее остановиться на таблице с данными шлюза и используемых полей – smsc_gateway. Здесь мы используем:

  • uuid – id пользователя, которому назначен данный шлюз
  • host – ip-адрес компьютера с модемами для подключения по ssh
  • port – порт мы тоже укажем, так как желательно не использовать стандартный 22-й, а также это позволит при необходимости использовать port forwarding
  • password – пароль ssh
  • gw_phone – фактической номер телефона
  • maxcount – ограничение на количество отправляемых сообщений
  • status – статус. 1 — активен, 0 — заблокирован.
  • gateway_id – канал этого модема в Gammu (помните, у нас может быть несколько модемов)
  • state – статус шлюза. lock — заблокирован.
  • comment – свободное поле с комментарием, чтобы просто делать заметки, если нужно

Классов будет всего 5:

  1. Авторизация пользователя – Users_Auth.class.php
  2. Работа с PDO – MYSQL_PDO.class.php
  3. Обработчик по работе с входящими данными SMS – SMS_data_handle.class.php
  4. Работа с Gammu – Gammu_SMS.class.php
  5. Возврат http ответов в json – http_response.class.php

Дальше я буду объяснять как поток данных будет ходить по API опираясь на базу данных. Мне кажется так нагляднее и понятнее. Также я приведу куски этого кода под спойлерами.

В итоге мы получаем такую последовательность действий.

Пользователь отправляет запрос с параметрами:

.../smsc.php?login={user_name}&pass={user_password}&tel={phone_number}&msg={message}&flash=1&replacemessages_id=1

Значения flash и replacemessages мы рассматривали в прошлой статье. В {phone_number} можно указать несколько номеров телефонов через ",". + в номере телефона указывать не нужно, но обязательно указывать номер в международном формате (для России это 7…). Так же в стоку можно добавить еще один параметр – &attempts={число} – количество попыток внутри одной отправки, то есть, если можем при отправке вернул ошибку, пытаться ли отправить тут же еще раз?

Вот, что происходит под капотом smsc.php

<?php

require_once __DIR__.'/functions/config.php';

XSS_secure();

if (!Users_Auth::do($PDO)) http_response::return(401, ["description" => "User not found or login / password is incorrect"]);

$sms_handle = new SMS_data_handle($PDO);
$sms_handle->save();


function XSS_secure() {
 
    function replace($arr) {
        $filter = array("<", ">");
        $filter_replace = array("<", ">");
 
        for ($i=0; $i < count($filter) ; $i++) {
            $str = str_replace($filter[$i], $filter_replace[$i], $arr);
        }
    return $str;
    }
 
    if ($_GET) $_GET = replace($_GET);
    if ($_POST) $_POST = replace($_POST);
 
}

?>

Первым делом мы подключаем файл с настройками – config.php:

require_once __DIR__.'/functions/config.php';

Содержание файла:

<?php
 
// ini_set('error_reporting', E_ALL);
// ini_set('display_errors', 1);
// ini_set('display_startup_errors', 1);
 
spl_autoload_register(function ($class_name) {
    require_once "classes/{$class_name}.class.php";
});
 
$PDO_param = [
'db_host' => '__', // database hostname or ip
'db_name' => '__', // database name
'db_user' => '__', // database username
'db_pass' => '__', // databse user password
'db_charset' => 'utf8',
'pdo_opt' => [
        PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
        PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_LAZY,
        PDO::ATTR_EMULATE_PREPARES => false,
        ]
];
 
$PDO = new MYSQL_PDO($PDO_param);
 
?>

делаем небольшую проверку на XSS, а далее проверяем авторизацию, вызывая метод класса Users_Auth::do($PDO):

class Users_Auth
{
    static function do($PDO)
    {
      if (!@$_REQUEST['login'] || !@$_REQUEST['pass']) http_response::return(403, ['description' => 'Check your login and password']);
     
// Проверяем авторизацию
      $user = $PDO->query("SELECT id, status FROM users WHERE login= ? AND password= ?", [$_REQUEST['login'], md5(trim($_REQUEST['pass']))]);
      if ($user->rowCount() == 0)
          return 0;
          // http_response::return(401, ["description" => "User not found. Login and password is incorrect"]);

      $row = $user->fetch();
      if ($row->status != 'active')
          return 0;
          // http_response::return(401, ["description" => "User status: {$row->status}"]);

      return 1;
    }
}

Если получили false – авторизация не удалась, возвращаем код и описание ошибки в json, если необходимо.

Если авторизация успешная вызываем $sms_handle->save(), проверяем переданы ли обязательные параметры – телефон и текст сообщения, проверяем в БД статус пользователя, разбираем строку запроса и приводим в нужный нам вид, удаляем пробелы и "+" из номера телефона, а также разделяем их по запятой. Таким образом получаем массив номеров телефонов и текста сообщения, которое нужно на них отправить. Делаем из этого json и сохраняем в таблицу очереди на отправку. Проверка на наличие телефона обязательна. Если попытаться отправлять сообщения без указания номера телефона, возникнет ошибка в Gammu, и шлюз будет занят на несколько секунд. Когда шлюз освободится возникнет аналогичная повторная ситуация, что в свою очередь создаст так называемую «пробку» и последующие сообщения из очереди просто не смогут уйти.

public function save() {

if (empty($_REQUEST['tel'])) http_response::return(400, ["success" => false, "description" => "Phone number is empty"]);
if (empty($_REQUEST['msg'])) http_response::return(400, ["success" => false, "description" => "Message is empty"]);
$msg = $_REQUEST['msg'];
$search = array(' ', '+');
$replace = array('', '');
$phone_array = explode(",", str_replace($search, $replace, $_REQUEST['tel']));

$query = $this->PDO->query("SELECT uuid FROM users WHERE login = ?", [$_REQUEST['login']]);
$user_uuid = $query->fetch();

$data = [
    'message' => $msg,
    'flash' => @$_REQUEST['flash'],
    'replacemessages_id' => @$_REQUEST['replacemessages_id'],
    'attempts' => @$_REQUEST['attempts']
];
$data['attempts'] = (@$_REQUEST['attempts']) ?: 1;

foreach ($phone_array as $phone) {
    $data['phone'] = $phone;
    $this->PDO->query("INSERT INTO sms_queue SET uuid= ?, data= ?, phone= ?", [$user_uuid->uuid, json_encode($data, JSON_UNESCAPED_UNICODE), $phone]);
}

http_response::return(200, ["success" => true, "description" => "Saved to queue"]);

  }

Дальше мы используем простой скрипт, который поставим в cron и будем вызывать раз в 5-10 секунд (по вкусу) – send_queue.php

<?php
 
require_once __DIR__.'/config.php';
 
$sms_handle = new SMS_data_handle($PDO);
$sms_handle->send();
 
/*
add follow lines to cron – crontab -e
don't forget to replace <full_path_to> to your really path

* * * * * ( php /<full_path_to>/send_queue.php )
* * * * * ( sleep 10 ; php /<full_path_to>/send_queue.php )
* * * * * ( sleep 20 ; php /<full_path_to>/send_queue.php )
* * * * * ( sleep 30 ; php /<full_path_to>/send_queue.php )
* * * * * ( sleep 40 ; php /<full_path_to>/send_queue.php )
* * * * * ( sleep 50 ; php /<full_path_to>/send_queue.php )
*/

?>

Он будет обращаться к методу класса обработчика сообщений SMS_data_handle->send(). Здесь уже начинается самое интересное.

Мы получаем сообщение за последние 10 минут без тегов статуса. Если нашли такое, ставим на него тег — process и берём в работу.

Извлекаем из тела json uuid пользователя, обращаемся к таблице и получаем список активных шлюзов. Идем в таблицу со счётчиком и проверяем, не превышен ли лимит на отправку. Если мы получили активный шлюз и счётчик не превышен, ставим на него тег — lock, чтобы никакой другой процесс уже не смог параллельно к нему обратиться. Все вызовы происходит внутри метода send(), но логика раскидана по другим методам класса. По указанному выше описанию работы метода эти обращения легко видны.

Далее мы создаем объект класса $send_proc = new Gammu_SMS($param) с параметрами и обращаемся к методу $send_proc->send($attr) с атрибутами

Весь код метода send():

public function send($с = 1) {
 
    $sended_sms = 0;
    for ($i = 0; $i < $с; $i++) {
 
      $sms_queue = $this->get_sms_queue(1);
      if (!$sms_queue->rowCount()) http_response::return(200, ["description" => "Nothing to do. Sent. count: {$sended_sms}"]);
 
      $sms_count = $sms_queue->rowCount();
      $msg_row = $sms_queue->fetch();
 
      $this->PDO->query("UPDATE sms_queue SET status = ? WHERE id = ?", ["process", $msg_row->id]);
 
      $user_gateway = ($this->get_gateway($msg_row->uuid));
 
      if (!$user_gateway) {
      $this->PDO->query("UPDATE sms_queue SET status = NULL WHERE id= ?", [$msg_row->id]);
      http_response::return(403, ["description" => "Not active gateways or get limit of message count"]);
      }
 
      $this->gateway_lock($user_gateway->id);
 
      $param = [
      'host' => $user_gateway->host,
      'port' => $user_gateway->port,
      'login' => 'root',
      'password' => $user_gateway->password,
      ];
 
      $sms_data = json_decode($msg_row->data);
      $sms_data->message = $sms_data->message;
 
      $attr = [
      'phone' => $sms_data->phone,
      'message' => $sms_data->message,
      'attempts' => $sms_data->attempts,
      'flash' => $sms_data->flash,
      'replacemessages_id' => $sms_data->replacemessages_id,
      'gateway' => $user_gateway->id,
      ];
      // sleep(5);
      $send_proc = new Gammu_SMS($param);

      if ($send_proc->send($attr)) {
      $this->sms_2archive($msg_row->id, $user_gateway->id);
      $this->update_gwcount($user_gateway->gw_phone);
      $sended_sms++;
 
      } else {
      $this->PDO->query("UPDATE sms_queue SET status = NULL WHERE id= ?", [$msg_row->id]);
      }
     
$this->gateway_release($user_gateway->id);
 
    }
 
    http_response::return(200, ["success" => true, "description" => "Message sent. Count: {$sended_sms}"]);
 
  }

Если объект вернул true, то переносим сообщение в архив и увеличиваем счётчик отправленных сообщений. Иначе снимаем тег proccess и через некоторое время будет повторная попытка отправки по cron.

Особо внимательные заметили, что мы вызываем метод с дефолтным параметром равным одному – send($с = 1). Параметр $c заложен «на перспективу» и позволяет нам, в случае необходимости получать пачку сообщений из базы данных и обрабатывать их отправку в цикле. Для этого в файле, вызываемом в cron нужно в вызове метода указать число сообщений для выборки их БД – $sms_handle->send({число});

Обратим внимание еще на один момент. В файле smsc.php есть возможность отправлять сообщения сразу после того, как оно было добавлено в БД. Для этого нужно раскомментировать следующую строку:

// $sms_handle->send();

Это позволит нам отказаться от cron, но есть один нюанс – желательно использовать этот метод, если вы отправляете сообщения только на один номер и запросы к шлюзу не могут быть чаще чем раз в 30 секунд. Иначе возможны ошибки связанные с наложением запросов и если шлюз занят, то сообщение не отправится.

Теперь наш шлюз работает через API и умеет отправлять сообщения.

Ну и бонусом мы сделаем простую форму для отправки сообщений с сайта. Ее код не нуждается в пояснении, она просто принимает от вас тест и отправляет POST-запрос на указанный нами скрипт. Единственное в блоке отправки ajax нужно заменить url: "/<*.php>" на адрес вашего скрипта smsc.php

Свой личный SMS-шлюз. Часть 2 – создаём API и форму отправки - 2Итак подведем итоги проделанной работы. Мы создали аппаратную платформу, научились отправлять сообщения через терминал и расширили возможности системы собственным API для легкого доступа к шлюзу устройств способных отправлять GET/POST-запросы. Хранить историю и балансировать нагрузку между картами и прочее. Все это сильно упрощает работу со шлюзом и позволяет хранить все в одном сервисе.

Внимание, я не претендую на великолепную красоту кода и буду рад любой объективной критике для понимания своих ошибок (в случае наличия) и совершенствования навыков.

Репозиторий данного проекта на Github

Автор: Дмитрий

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js