В предыдущей статье я описал как настроил и собрал GSM <> SIP систему на базе Asterisk. В этой статье расскажу как быть с входящими SMS, если получатель не в сети (не прошел регистрацию на PBX).
Проблема
Если посмотреть предыдушую статью, видно, что SMS (MESSAGE(body)) преобразуется из BASE64 в plane text на системе с модемом. Это остаток от дебага, когда нужно было видеть что пришло на модем в консоли Asterisk. Я не стал менять этого поведения, так как у меня сохраняется проблема составных SMS (когда сообщение разбито на несколько частей). С ней я буду разбераться позже.
Далее MESSAGE(body) передается уже в виде SIP Message на центральную PBX, где обрабатывется согласно dial plan.
И тут возникала проблема - если получатель сообщения (он же extension) был в момент его прихода не в сети - сообщение безвозвратно теряется.
Я считаю что GSM у нас есть всегда, по этому не стал заморачиваться с исходящими сообщениями.
Задача
Сегодня поговорим как создать очередь SIP сообщений, обеспечить их хранение и повторную доставку.
Решение
У Asterisk есть приложение очереди (message queue). Но само по себе это приложение не может писать Message(body) куда либо "на потом", так как во первой нужно для роутинга голосовых вызовов, а так же доставляемых сообщений (из серии - у нас поток, а приемник медленный или занят).
Придется использовать костыль - систему внешних скриптов, которые будут создавать файлы очереди и помещать их в специальную дирректорию, откуда Asterisk их будет читать. Задача состоит в том, чтоб помещать недоставленные сообщения в call file, который будет вгружаться в очередь, и повторять попытку доставки несколько (N) раз через определенные (T) промежутки времени.
Я рекомендую обращаться к справкам по всем упоминаемым мной фишкам Asterisk'а. Читая описание синтаксиса очень часто понятно где что не сработало.
Оба действия делаются двумя приложениями: app_system и pbx_spool. Первое - за вызовы скриптов, второе - за обработку call file.
Нужно проверять несколько условий:
Зарегистрирован ли целевой получатель на сервере.
Удалось ли доставить сообщение.
Сообщение которое мы пытаемся доставить пришло из очереди, или новое.
Так же важно помнить, что так как мы используем скрипт, ему придется передавать аргументы, а по сути - вызывать строчку в shell, что несет серьезные риски. По этому придется опять преобразовать MESSAGE(body) в BASE64 и расшифровывать его обратно перед доставкой в SIP клиент.
Не забываем подгрузить func_base64.
Диалплан
Ну что, поехали создавать? Все что мы делаем - делаем на центральной PBX.
[incoming-sms]
exten = _1.,1,Verbose(1, "Incoming SMS from ${CALLERID(num)} GSM Gateway to ${EXTEN}")
same = n,NoOp(To ${MESSAGE(to)})
same = n,NoOp(From ${MESSAGE(from)})
same = n,NoOp(Body ${MESSAGE(body)})
same = n,MessageSend(${MESSAGE(to)},${MESSAGE(from)})
same = n,NoOp(Send status is ${MESSAGE_SEND_STATUS})
;same = n,GotoIf($["${MESSAGE_SEND_STATUS}" != "SUCCESS"]?handlefailedmsg)
same = n,GotoIf($["${MESSAGE_SEND_STATUS}" != "SUCCESS"]?chktq)
same = n,Hangup()
; Notify sender that message was not delivered
;same = n(handlefailedmsg),NoOp(Sending error back to user)
;same = n,Set(SRC=${MESSAGE(from)})
;same = n,Set(DST=${MESSAGE(to)})
;same = n,Set(MSG=${MESSAGE(body)})
;same = n,Set(MESSAGE(body)="[${STRFTIME(${EPOCH},,%d%m%Y-%H:%M:%S)}] Your message to ${EXTEN} has failed. Sending when available")
;same = n,ExecIf($["${CUT(MESSAGE(from),<,2)}" != "" ]?Set(ME_1=${CUT(MESSAGE(from),<,2)}):Set(ME_1=${MESSAGE(from)}))
;same = n,Set(ACTUALFROM=${ME_1})
;same = n,MessageSend(${ACTUALFROM},ServiceCenter)
;same = n,GotoIf($["${INQUEUE}" != "1"]?startq)
;same = n,Hangup()
; Check that we are not in queue
same = n(chktq),GotoIf($["${INQUEUE}" != 1 ]?startq)
same = n,Hangup()
; Starting Queue for messages
same = n(startq),NoOp(Queueing message for offline)
same = n,Set(MSGTIME=${STRFTIME(${EPOCH},,%d%m%Y-%H:%M:%S)})
same = n,Set(SRC="${BASE64_ENCODE(${MESSAGE(from)})}")
same = n,Set(DST=${BASE64_ENCODE(${MESSAGE(to)}})
same = n,Set(MSG=${MESSAGE(body)})
same = n,Set(DUMP_MSG="${MSGTIME} ${MSG}")
; We do not want our BASH to execute strange things... so BASE64 encoding
same = n,Set(MSG="${BASE64_ENCODE(${DUMP_MSG})}")
same = n,System(/var/lib/asterisk/agi-bin/astqueue.sh -SRC ${SRC} -DST ${DST} -MSG ${MSG})
same = n,Hangup()
[app-fakeanswer]
exten = _1.,1,Verbose(1, "Processing offline message queue for ${EXTEN}")
same = n,Set(DESTDEV=${EXTEN})
same = n,Set(THISDEVSTATE=${DEVICE_STATE(PJSIP/${DESTDEV})})
same = n,GotoIf($["${THISDEVSTATE}" = "UNAVAILABLE"]?hang)
same = n,GotoIf($["${THISDEVSTATE}" = "UNKNOWN"]?hang)
same = n,Answer
same = n,Hangup
same = n(hang),Hangup()
Давайте разберем по блокам.
Вход в очередь
Начало стандартное, там ничего интересного, пока мы не попадаем к строкам
;same = n,GotoIf($["${MESSAGE_SEND_STATUS}" != "SUCCESS"]?handlefailedmsg)
same = n,GotoIf($["${MESSAGE_SEND_STATUS}" != "SUCCESS"]?chktq)
Мы проверяем смогли ли мы доставить сообщение. У него есть статус и мы будем продолжать если он не в значении SUCCESS.
Первая строка (я не использую) выкидывает нас в блок отправки уведомления отправителю. Так как никакого реального механизма уведомить о судьбе сообщения нет (у нас SMS доставилось на модем), нужно ручками описать процедуру формирования и отправки такого уведомления. Весь блок строк 11-21 я не сильно проверял. Что-то там будет работать, но это здесь чисто на будущее, если я решу это использовать.
Проверка источника сообщения
Второй блок проверяет, откуда это сообщение в контексте и направляет в соответвующие обработчики
; Check that we are not in queue
same = n(chktq),GotoIf($["${INQUEUE}" != 1 ]?startq)
same = n,Hangup()
Вторая строка - отсылка к проверке не пришло ли сообщение из очереди. Нужно понимать, что нам делать с шифрованием BASE64, так как сообщения из очереди у нас зашифрованы (будет видно ниже)
Т.е. если переменная INQUEU не в состоянии 1, считаем что сообщение пришло к нам из предыдущего блока, т.е. было передано оконечным PBX, но не доставлено адресату. И да, эта переменная появится значительно позже и будет прочитана из call файла, а установлена - скрпитом.
Создание очереди
; Starting Queue for messages same = n(startq),NoOp(Queueing message for offline)
same = n,Set(MSGTIME=${STRFTIME(${EPOCH},,%d%m%Y-%H:%M:%S)}) same = n,Set(SRC="${BASE64_ENCODE(${MESSAGE(from)})}")
same = n,Set(DST=${BASE64_ENCODE(${MESSAGE(to)}}) same = n,Set(MSG=${MESSAGE(body)})
same = n,Set(DUMP_MSG="${MSGTIME} ${MSG}")
Второй строкой мы генерируем уникальный идентификатор для последующиего использования в сообщении. См строку четыре.
Третьей строкой шифруем получателя. Shell обычно не любит всякие там + и < и мы все это превращаем в безобидную абракадабру для дельнейшего использования.
Четвертой строкой мы формируем текст, который будет помещен в очередь. Суть проста - когда мы получим сообщение из очереди, в нем будет указана дата-время когда оно в очередь поместилось и сам текст. В простивном случае мы просто получили бы текст сообщения без возможности узнать когда же мы его пропустили.
Запрос скрипта и помещение в очередь
; We do not want our BASH to execute strange things... so BASE64 encoding
same = n,Set(MSG="${BASE64_ENCODE(${DUMP_MSG})}")
same = n,System(/var/lib/asterisk/agi-bin/astqueue.sh -SRC ${SRC} -DST ${DST} -MSG ${MSG})
same = n,Hangup()
Следующий блок у нас кодирует всю строку сообщения (DATE+Message(body)) вместе в BASE64 для получения единой строки.
А далее мы вызываем внешний скрипт управления, которому в качестве аргументов передаем источник, получателя и текст сообщения.
Все, или нет?
Нет. Пока я не показал вам сам скрипт, не ясно зачем у нас есть новый контекст app-fakeanswer
[app-fakeanswer]
exten = _1.,1,Verbose(1, "Processing offline message queue for ${EXTEN}")
same = n,Set(DESTDEV=${EXTEN})
same = n,Set(THISDEVSTATE=${DEVICE_STATE(PJSIP/${DESTDEV})})
same = n,GotoIf($["${THISDEVSTATE}" = "UNAVAILABLE"]?hang)
same = n,GotoIf($["${THISDEVSTATE}" = "UNKNOWN"]?hang)
same = n,Answer
same = n,Hangup
same = n(hang),Hangup()
Ответ до безобразия прост. Когда сообщение будет обрабатываться message_queue оно попадает в этот контекст и идет проверка на доступность получателя (extension). Если получатель не зарегистрирован на сервере, мы выходим, если зарегистрирован - передаем сообщение обратно в обработку в соответствующий контекст incoming-sms
Скрипт создания call file
И так, теперь самое интересное.
Нам нужно воспользоваться так называемыми AGI приложениями, а по сути - bash скриптом, в котором мы пережуем аргументы, проверим есть ли у нас уже сообщение для этого получателя и создадим call file , который потом положим в специальную дирректорию, из которой его будет вычитывать Asterisk.
Оба скрипта закидываем в /var/lib/asterisk/agi-bin/ или туда, где у вас расположена agi директория, а так же не забываем придать им +x (право исполнения) и владельца из-под которого у вас работает Asterisk (chmod & chown).
Я модифицировал найденный в сети скрипт несколько адаптировав его под свои пути и исправив пару косяков.
#!/bin/bash
##############################################################################
# v0.2 #
# copyleft Sanjay Willie sanjayws@gmail.com #
# SCRIPT PURPOSE: GENERATE SMS OFFLINE QUEUE #
# GEN INFO: Change variables sections #
##############################################################################
# This script was edit by Michael A. Gates #
# because it didn't work in freepbx 5.11 #
# I am by no means a Linux guy or a Asterisk #
# guy. Without Sanjay Willie's work I could #
# not have done this. #
# #
#Contact:michael.allen.gates@gmail.com #
#added message ordering from #
#http://www.irishvoip.com/w/knowledgebase.php?action=displayarticle&id=13 #
##############################################################################
#VARIABLES
maxretry=10000 #Number of Atempts for sending the sms
retryint=60 #Number of Seconds between Retries
#CONSTANTS
ERRORCODE=0
d_unique=`date +%s`
d_friendly=`date +%T_%D`
astbin=`which asterisk`
myrandom=$[ ( $RANDOM % 1000 ) + 1 ]
#
function bail()
{
echo "SMS:[$ERRORCODE] $MSGOUT. Runtime:$d_friendly. UniqueCode:$d_unique"
exit $ERRORCODE
}
function gencallfile(){
filename=$1
destexten=$2
source=$3
dest=$4
message=$5
mydate=`date +%d%m%y`
logdate=`date`
#dest=echo $dest | grep -d
#
echo -e "Channel: Local/$destexten@app-fakeanswer
CallerID: $source
Maxretries: $maxretry
RetryTime: $retryint
Context: incoming-sms
Extension: $destexten
Priority: 1
Set: MESSAGE(body)=$message
Set: MESSAGE(to)=$dest
Set: MESSAGE(from)=$source
Set: INQUEUE=1 "> /var/spool/asterisk/tmp/$filename
# move files
chmod 777 /var/spool/asterisk/tmp/$filename
sleep 3
#
# Check to see if there is already a message for this extension queued
# if so then move to the hold folder and let the cron job astcron.sh check for delivery of the queued message
# and only then deliver the hold messages. This will make sure the messages are delivered in order
#
ifexist=`ls /var/spool/asterisk/outgoing/|grep call | grep -c $destexten`
if [[ "$ifexist" == "0" ]]; then
#
# move file to outgoing folder
#
mv /var/spool/asterisk/tmp/$filename /var/spool/asterisk/outgoing/
#echo "moved"
else
#
# move file to hold folder
#
mv /var/spool/asterisk/tmp/$filename /var/spool/asterisk/hold/
#echo "holded"
fi
#
#exit $ERRORCODE
bail
}
while test -n "$1"; do
case "$1" in
-SRC)
source="$2"
echo $source
shift
;;
-DST)
dest="$2"
echo $dest
shift
;;
-MSG)
message="$2"
echo $message
shift
;;
-TIME)
originaltime="$2"
echo $originaltime
shift
;;
esac
shift
done
# decoding BASE64
source=`echo $source | base64 -d`
dest=`echo $dest | base64 -d`
message=`echo $message | base64 -d`
originaltime=`echo $originaltime | base64 -d`
#[checking for appropriate arguments]
if [[ "$source" == "" ]]; then
echo "ERROR: No source. Quitting."
ERRORCODE=1
bail
fi
if [[ "$dest" == "" ]]; then
echo "ERROR: No usable destination. Quitting."
ERRORCODE=1
bail
fi
if [[ "$message" == "" ]]; then
echo "ERROR: No message specified.Quitting."
ERRORCODE=1
bail
fi
#[End Argument checking]
# Check to see if extension exist
destexten=`echo $dest | cut -d\@ -f1 | cut -d\: -f2`
ifexist=`$astbin -rx "pjsip show endpoints" | grep -c $destexten`
if [[ "$ifexist" == "0" ]]; then
echo "Destination extension don't exist, exiting.."
ERRORCODE=1
baduser=$destexten
destexten=`echo $source | cut -d\@ -f1 | cut -d\: -f2`
temp=$source
source=$dest
dest=$temp
message="The user $baduser does not exist, please try your message again using a different recipient.:("
filename="$destexten-$d_unique.$myrandom.NoSuchUser.call"
gencallfile "$filename" "$destexten" "$source" "$dest" "$message"
bail
fi
#End of Check
# If that conditions pass, then we will queue,
# you can write other conditions too to keep the sanity of the looping
destexten=`echo $dest | cut -d\@ -f1 | cut -d\: -f2`
filename="$destexten-$d_unique.$myrandom.call"
gencallfile "$filename" "$destexten" "$source" "$dest" "$message"
bail
Что тут интересного:
Строки 20 и 21: задают количество повторов и время между попытками.
Строка 50: контекст куда выкидывать сообщения.
Строка 56: это установка статуса очереди (переменная INQUEUE), а так же мы указываем путь к временной директории, доступной Asterisk'у для создания временного файла.
Строки 113-116: декодируют все аргументы.
Строка 138: проверяет а есть ли вообще получатель и если нет, выходит с сообщением об ошибке. Сообщение теряется. Именно тут была основная закоыврка, так как нужно верно вызвать комманду в Asterisk согласно вашему конкретному случаю.
Строка 159 и далее: формируют файл, приделывают ему псевдо-случайное название (для правильного порядка в очереди и избегания перезаписи).
А как быть с порядком доставки?
А очень просто. Есть еще один скрпит, который проверяет поступающие call file'ы и распределяет их по порядку для доставки.
#!/bin/bash
# This file should go in /var/lib/asterisk/agi-bin
# make sure you change your permissions of astcron.sh to 775
#Change to hold directory
#
cd /var/spool/asterisk/hold
#
# Check through all queued files in sort order (so we always get the oldest first)
#
for filename in `ls -v *.call`; do
destexten=`echo $filename | cut -d - -f1 `
echo "Checking extension" $destexten "for file" $filename "for existing messages"
ifexist=`ls /var/spool/asterisk/outgoing/| grep call | grep -c $destexten`
#
# if extension doesnt exist then queued message has been delivered
# so we can move the waiting message now
#
if [[ "$ifexist" == "0" ]]; then
echo "No existing message for " $destexten
#
# move file to outgoing folder
#
echo "Moving filename" $filename "to outgoing"
mv /var/spool/asterisk/hold/$filename /var/spool/asterisk/outgoing/
#
# If we actually do a move then delay just in case
# there is more than one waiting message for that extension
#
sleep 3
fi
done
Тут нужно не забыть создать еще одну дирркеторию hold, в которой будут храниться call фвйлы до момнета когда их можно перемещать на доставку в папку outgoing.
Опять же не забывайте редактировать пути согласно вашим реалиям.
И последним, добавляем в crontab следующую строчку
* * * * * cronic /var/lib/asterisk/agi-bin/astcron.sh /dev/null 2>&1 || true
Эта штука дергает ежесекундно скрипт проверки и организации очереди.
На закуску
Сохранив ваш extensions.conf идем в астериск и делаем dialplan reload.
Отключаем клиент или sip-телефон от сети и шлам сообщение. Подлкючаемся - вуаля (если все сделано верно).
P.S. пока что в очереди застреают многосегментные сообщения, как я уже говорил - это на следующий раз.
Комментарии и замечания очень приветствуются.
Комментарии (7)
Ovoshlook
29.07.2022 00:01System может плохо отрабатывать на больших нагрузках + можно уменьшить количество скриптов если использовать базу данных как хранилище недоставленных сообщений ( например sqlite или odbc ). Тогда останется только один скрипт, который будет по крону ходить в бд и формировать call file, или даже сразу вызывать originate на context через cli.
HellKaim Автор
29.07.2022 00:03Я за, покажете диалплан?
ЗЫ. У меня все для лчиных нужд и нагрузка там прямо скажем - никакая. А вот мультисегментные смс да еще и с разыными языками - вот проблема
LostAlly
Спасибо за ваши статьи.
Может быть вы используете, что-то как клиент астериск для звонков в виде приложения на андроид или кнопочный сотовый с возможностью подключаться к сип.
HellKaim Автор
Телефонов со встроенным SIP современных я не припомню.
Есть несколько приложений: PortSIP, Zioper и еще какое-то, не вспомню с супер сложными (богатыми) настройками. Почему не вспомню - так как оно не уважает роутинг, который предоставляет VPN я его практически сразу исключил из тестирования.
Основная проблема - протокол SIP режут во многих странах. Приходится все гонять через VPN с маршрутами в сторону центральной PBX.
HellKaim Автор
Еще есть Browser Phone - проект на Java и WebRTC. Требует Apatche или Nginx и некой докрутки Asterisk. Работает вполне сносно, но в последней версии по непонятной причине текстовые сообщения отсылаются только если добавлять номер в поле extension.
Вернее как, причина понятна, не понятно почему так сделано. Обещают в следующем релизе как-то хитро пофиксить.
mapnik
Всё хочу попробовать Telegram в качестве, скажем так, "SIP-клиента". Привлекает его относительная устойчивость к джиттеру и ненужность VPN, который сам по себе вносит приличную задержку и (главное!) дополнительный расход батареи
HellKaim Автор
Как я понял, есть некая проблема TG2SIP issue 63