Pull to refresh

Отправка СМС используя jSMPP методами UDH, SAR, Payload

Reading time10 min
Views9.4K
Наша компания занимается рассылками 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 сервиса. Спасибо за прочтение.
Tags:
Hubs:
+9
Comments4

Articles