Наша компания занимается рассылками email и sms. На начальных этапах для рассылок смс мы использовали API посредника. Компания растет и клиентов становится все больше, мы приняли решение написать свой софт для отправки смс по протоколу smpp. Это нам позволило отправлять провайдеру набор байтов, а он уже распределял трафик по странам и внутренним операторам.

После ознакомления с доступными и бесплатными библиотеками для отправки смс выбор пал на jsmpp. Информацию по использованию, кроме этой, в основном разбирал из google по jsmpp. Описание самого протокола SMPP на русском языке тыц.

Надеюсь, что этой статьей я многим облегчу жизнь при написании SMPP сервиса.

Введение


Давайте начнем с поэтапного анализа отправки смс. Логика отправки смс выглядит следующим образом:

1. Клиент передает Вам смс которую хочет отправить (в виде json):

{
        "sms_id": "test_sms_123",
        "sender": "Ваше альфа имя",
        "phone": "380959999900",
        "text_body": "Привет! Ты выиграл 1000000$!",       
        "flash": 0
}

Параметр flash говорит, о том что это не flash смс.

2. Когда мы получаем данные, мы начинаем их готовить под тот способ, каким мы будете его отправлять провайдеру, а именно: UDH, SAR, Payload. Все зависит от того какой способ поддерживает Ваш провайдер.

3. Вы передаете данные провайдеру и он Вам отдает в ответ строку, которая идентифицирует прием смс провайдером, я назвал ее transaction_id.

4. Отправка смс оставляет за провайдером право на возвращения Вам окончательного статуса (доставлено, недоставлено и т.д.) на протяжении 48 часов. Поэтому Вам прийдется сохранять Ваш sms_id и полученный transaction_id.

5. Когда смс доставилась получателю, провайдер Вам передает transaction_id и статус (DELIVERED).

Статусы


По статусам посоветую 2 статьи: здесь описывается и дается название только 10 статусам, а здесь уже полный перечень кодов статусов и их полное описание.

Maven зависимость для API jSmpp:

        <dependency>
            <groupId>com.googlecode.jsmpp</groupId>
            <artifactId>jsmpp</artifactId>
            <version>2.1.0-RELEASE</version>
        </dependency>

Опишу основные классы, с которыми мы будем работать:

  • SMPPSession — сессия, которая создается для пользователя, которого Вы зарегистрировали у провайдера и со счета которого снимаются деньги;
  • SessionStateListener — слушатель извещает Вас о том, что статус Вашей сессии был изменен, например сессия закрылась;
  • MessageReceiverListener — слушатель возвращает объект DeliverSm, в котором хранится transaction_id, статус и мобильный номер;

Способ отправки Payload


Самый простой способ отправки это payload. Вне зависимости от того, какой длины смс Вы отправляете, Вы отправляете смс одним пакетом данных. Провайдер сам заботится о разбиении смс на части. Склейка частей уже происходит на телефоне получателя. С него и начнем обзор реализации отправки смс.

Для начала нам нужно подключиться к провайдеру. Для этого нам необходимо создать сессию и ее слушателя, а также слушателя, который реагирует на прием статусов отправленных смс. Ниже приведен пример метода createSmppSession подключения к провайдеру, данные которого хранятся в мною созданном классе SmppServer. Он содержит такие данные: логин, пароль, ip, порт и т.д.

Метод создающий SMPP подключение
protected SMPPSession session;
protected SmppServer server;
private ExecutorService receiveTask;
private SessionStateListener stateListener;

... // some of your code

public void createSmppSession() {
        StopWatchHires watchHires = new StopWatchHires();
        watchHires.start();
        log.info("Start to create SMPPSession {}", sessionName);

        try {
            session = new SMPPSession();
            session.connectAndBind(server.getHostname(),
                    server.getPort(),
                    new BindParameter(
                            BindType.BIND_TRX,
                            server.getSystemId(),
                            server.getPassword(),
                            server.getSystemType(),
                            TypeOfNumber.UNKNOWN,
                            NumberingPlanIndicator.UNKNOWN,
                            null));

            stateListener = new SessionStateListenerImpl();
            session.addSessionStateListener(stateListener);
            session.setMessageReceiverListener(new SmsReceiverListenerImpl());
            session.setTransactionTimer(TRANSACTION_TIMER);

            if (Objects.isNull(receiveTask)
                    || receiveTask.isShutdown()
                    || receiveTask.isTerminated()) {

                this.receiveTask = Executors.newCachedThreadPool();
            }

            watchHires.stop();

            log.info("Open smpp session id {}, state {}, duration {} for {}",
                    session.getSessionId(), session.getSessionState().name(),
                    watchHires.toHiresString(), sessionName);

        } catch (IOException e) {

            watchHires.stop();

            if (SmppServerConnectResponse.contains(e.getMessage())) {
                log.error("Exception while SMPP session creating. Reason: {}. Duration {}, {}",
                        e.getMessage(), watchHires.toHiresString(), sessionName);

                close();
                return;

            } else if (e instanceof UnknownHostException) {
                log.error("Exception while SMPP session creating. Unknown hostname {}, duration {}, {}",
                        e.getMessage(), watchHires.toHiresString(), sessionName);
                close();
                return;
            } else {
                log.error("Failed to connect SMPP session for {}, duration {}, Because {}", 
                       sessionName, watchHires.toHiresString(), e.getMessage());
            }
        }

        if (!isConnected()) {
            reconnect();
        }
    }


Из этого примера видно, что мы используем объеты классов SmsReceiverListenerImpl и SessionStateListenerImpl для создания сессии. Первый отвечает за прием ��татусов отправленных смс, второй — слушатель сессии.

Класс SessionStateListenerImpl в методе onStateChange получает класс старого состояния сессии и нового. В данном примере, если сессия не подключена, происходит попытка переподключения.

Слушатель SMPP сессии
  /**
     * This class will receive the notification from {@link SMPPSession} for the
     * state changes. It will schedule to re-initialize session.
     */
    class SessionStateListenerImpl implements SessionStateListener {
        @Override
        public void onStateChange(SessionState newState, SessionState oldState, Object source) {
            if (!newState.isBound()) {
                log.warn("SmppSession changed status from {} to {}. {}", 
                         oldState, newState, sessionName);
                reconnect();
            }
        }
    }


Пример SmsReceiverListenerImpl. Вам придется переопределить 3 метода: onAcceptDeliverSm, onAcceptAlertNotification, onAcceptDataSm. Нам для отправки смс нужен только первый. Он будет получать от провайдера transaction_id, под которой зарегистрировал провайдер нашу смс и статус. В этом примере Вы встретите два класса: SmppErrorStatus и StatusType — это enum-классы, которые хранят статусы с ошибками и статусы отправки (отправлено провайдеру, не отправлено провайдеру и т.д.) соответственно.

Слушатель смс статусов
/*The logic on this listener should be accomplish in a short time,
    because the deliver_sm_resp will be processed after the logic executed.*/
    class SmsReceiverListenerImpl implements MessageReceiverListener {

        @Override
        public void onAcceptDeliverSm(DeliverSm deliverSm) throws ProcessRequestException {
            if (Objects.isNull(deliverSm)) {
                log.error("Smpp server return NULL delivery answer");
                return;
            }
            try {
                // this message is delivery receipt
                DeliveryReceipt delReceipt = deliverSm.getShortMessageAsDeliveryReceipt();
                //delReceipt.getId() must be equals transactionId from SMPPServer
                String transactionId = delReceipt.getId();
                StatusType statusType;
                String subStatus;
                if (MessageType.SMSC_DEL_RECEIPT.containedIn(deliverSm.getEsmClass())) {

                    //  && delReceipt.getDelivered() == 1
                    statusType = getDeliveryStatusType(delReceipt.getFinalStatus());
                    SmppErrorStatus smppErrorStatus =
                            SmppErrorStatus.contains(delReceipt.getError());

                    if (smppErrorStatus != null)
                        subStatus = smppErrorStatus.name();
                    else
                        subStatus = delReceipt.getError();
                } else {
                    statusType = StatusType.SMS_UNDELIVERED;
                    // this message is regular short message
                    log.error("Delivery SMS event has wrong receipt. Message: {}", deliverSm.getShortMessage());
                    subStatus = SmppErrorStatus.INVALID_FORMAT.name();
                }

// some providers return phone number in deliverSm.getSourceAddr()
                String phoneNumber = deliverSm.getDestAddress();
                saveDeliveryStatus(transactionId, statusType, subStatus, phoneNumber));
                log.info("Receiving delivery receipt from {} to {}, transaction id {}, status {}, subStatus {}",
                        deliverSm.getSourceAddr(), deliverSm.getDestAddress(), 
                       transactionId, statusType, subStatus);

            } catch (InvalidDeliveryReceiptException e) {
                log.error("Exception while SMS is sending, destination address {}, {}", 
                       deliverSm.getDestAddress(), e.getMessage(), e);
            }
        }

        @Override
        public void onAcceptAlertNotification(AlertNotification alertNotification) {
            log.error("Error on sending SMS message: {}", alertNotification.toString());
        }

        @Override
        public DataSmResult onAcceptDataSm(DataSm dataSm, Session source) throws ProcessRequestException {
            log.debug("Event in SmsReceiverListenerImpl.onAcceptDataSm!");
            return null;
        }

        private StatusType getDeliveryStatusType(DeliveryReceiptState state) {<cut />
            if (state.equals(DeliveryReceiptState.DELIVRD))
                return StatusType.SMS_DELIVERED;
            else if (state.equals(DeliveryReceiptState.ACCEPTD))
                return StatusType.ACCEPTED;
            else if (state.equals(DeliveryReceiptState.DELETED))
                return StatusType.DELETED;
            else if (state.equals(DeliveryReceiptState.EXPIRED))
                return StatusType.EXPIRED;
            else if (state.equals(DeliveryReceiptState.REJECTD))
                return StatusType.REJECTED;
            else if (state.equals(DeliveryReceiptState.UNKNOWN))
                return StatusType.UNKNOWN;
            else
                return StatusType.SMS_UNDELIVERED;
        }

    }


Ну и наконец-то самый главный метод — метод отправки смс. Выше описанный JSON я дессериализировал в объект SMSMessage, поэтому, встречая объект этого класса, знайте, что он содержит всю нужную информацию про отправляемую смс.

Метод sendSmsMessage, описанный ниже, возвращает объект класса SingleSmppTransactionMessage, который содержит в себе данные об отправленной смс с transaction_id, который был присвоен провайдером.

Класс Gsm0338 помогает определить содержание кириллических символов в смс. Это важно, так как мы должны сообщать провайдеру об этом. Этот класс был построен ��а основе документа.

Enum класс SmppResponseError был построен на основе ошибок, которые может возвращать SMPP сервер провайдера, ссылка тут.

Метод отправки смс
public SingleSmppTransactionMessage sendSmsMessage(final SMSMessage message) {

        final String bodyText = message.getTextBody();
        final int smsLength = bodyText.length();
        OptionalParameter messagePayloadParameter;
        String transportId = null;
        String error = null;
        boolean isUSC2 = false;
        boolean isFlashSms = message.isFlash();

        StopWatchHires watchHires = new StopWatchHires();
        watchHires.start();

        log.debug("Start to send sms id {} length {}",
                message.getSmsId(), smsLength);

        try {

            byte[] encoded;
            if ((encoded = Gsm0338.encodeInGsm0338(bodyText)) != null) {
                messagePayloadParameter =
                        new OptionalParameter.OctetString(
                                OptionalParameter.Tag.MESSAGE_PAYLOAD.code(),
                                encoded);

                log.debug("Found Latin symbols in sms id {} message", message.getSmsId());

            } else {
                isUSC2 = true;
                messagePayloadParameter =
                        new OptionalParameter.OctetString(
                                OptionalParameter.Tag.MESSAGE_PAYLOAD.code(),
                                bodyText,
                                "UTF-16BE");
                log.debug("Found Cyrillic symbols in sms id {} message", message.getSmsId());
            }

            GeneralDataCoding dataCoding = getDataCodingForServer(isUSC2, isFlashSms);

            log.debug("Selected data_coding: {}, value: {}, SMPP server type: {}",
                    dataCoding.getAlphabet(),
                    dataCoding.toByte(),
                    server.getServerType());

            transportId = session.submitShortMessage(
                    "CMT",
                    TypeOfNumber.ALPHANUMERIC,
                    NumberingPlanIndicator.UNKNOWN,
                    message.getSender(),
                    TypeOfNumber.INTERNATIONAL,
                    NumberingPlanIndicator.ISDN,
                    message.getPhone(),
                    ESM_CLASS,
                    ZERO_BYTE,
                    ONE_BYTE,
                    null,
                    null,
                    rd,
                    ZERO_BYTE,
                    dataCoding,
                    ZERO_BYTE,
                    EMPTY_ARRAY,
                    messagePayloadParameter);


        } catch (PDUException e) {
            error = e.getMessage();
            // Invalid PDU parameter
            log.error("SMS id:{}. Invalid PDU parameter {}",
                    message.getSmsId(), error);
            log.debug("Session id {}, state {}. {}", session.getSessionId(), session.getSessionState().name(), e);
        } catch (ResponseTimeoutException e) {
            error = analyseExceptionMessage(e.getMessage());
            // Response timeout
            log.error("SMS id:{}. Response timeout: {}",
                    message.getSmsId(), e.getMessage());
            log.debug("Session id {}, state {}. {}", session.getSessionId(), session.getSessionState().name(), e);
        } catch (InvalidResponseException e) {
            error = e.getMessage();
            // Invalid response
            log.error("SMS id:{}. Receive invalid response: {}",
                    message.getSmsId(), error);
            log.debug("Session id {}, state {}. {}", session.getSessionId(), session.getSessionState().name(), e);
        } catch (NegativeResponseException e) {
            // get smpp error codes
            error = String.valueOf(e.getCommandStatus());
            // Receiving negative response (non-zero command_status)
            log.error("SMS id:{}, {}. Receive negative response: {}",
                    message.getSmsId(), message.getPhone(), e.getMessage());
            log.debug("Session id {}, state {}. {}", session.getSessionId(), session.getSessionState().name(), e);
        } catch (IOException e) {
            error = analyseExceptionMessage(e.getMessage());
            log.error("SMS id:{}. IO error occur {}",
                    message.getSmsId(), e.getMessage());
            log.debug("Session id {}, state {}. {}", session.getSessionId(), session.getSessionState().name(), e);
        } catch (Exception e) {
            error = e.getMessage();
            log.error("SMS id:{}. Unexpected exception error occur {}",
                    message.getSmsId(), error);
            log.debug("Session id {}, state {}. {}", session.getSessionId(), session.getSessionState().name(), e);
        }

        watchHires.stop();

        log.info("Sms id:{} length {} sent with transaction id:{} from {} to {}, duration {}",
                message.getSmsId(), smsLength,
                transportId, message.getSender(),
                message.getPhone(), watchHires.toHiresString());

        return new SingleSmppTransactionMessage(message, server.getId(), error, transportId);
    }

    private GeneralDataCoding getDataCodingForServer (boolean isUCS2Coding, boolean isFlashSms){

        GeneralDataCoding coding;

        if (isFlashSms) {
            coding = isUCS2Coding ? UCS2_CODING : DEFAULT_CODING;
        } else {
            coding = isUCS2Coding ? UCS2_CODING_WITHOUT_CLASS : DEFAULT_CODING_WITHOUT_CLASS;
        }

        return coding;
    }

    /**
     * Analyze exception message for our problem with session
     * While schedule reconnecting session sms didn't send and didn't put to resend
     */
    private String analyseExceptionMessage(String exMessage){

        if(Objects.isNull(exMessage))
            return exMessage;

        if (exMessage.contains("No response after waiting for"))
            return SmppResponseError.RECONNECT_RSPCTIMEOUT.getErrCode();

        else if (exMessage.contains("Cannot submitShortMessage while"))
            return SmppResponseError.RECONNECT_CANNTSUBMIT.getErrCode();

        else if (exMessage.contains("Failed sending submit_sm command"))
            return SmppResponseError.RECONNECT_FAILEDSUBMIT.getErrCode();

        return exMessage;
    }


В следующей статье я опишу метод отправки смс использующий UDH, ссылка будет здесь. Этот вариант обязывает Вас переводит сообщение в байты, после чего делить их на подсообщения и в первых битах указывать их нумерацию и количество. Будет весело.

Github-ссылка. Надеюсь моя статья упростит Вам разработку SMPP сервиса. Спасибо за прочтение.