Взаимодействие с сервером Asterisk из java-приложения через Asterisk Managment Interface (AMI)


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

Чтобы не искать нужные крупицы информации на форумах в стиле ответ-вопрос, прилагаю небольшой туториал о взаимодействии с сервером Asterisk из java.

Важно: я предполагаю, что раз вы дошли до стадии написания кода, то у вас уже есть работающий Asterisk сервер, к которому можно обращаться.

1) Что выбрать, чтобы было удобно работать?

Определенно — Asterisk Managment Interface (AMI): данный интерфейс обладает полным набором функций, позволяющим совершать звонок, слушать события в реальном времени с сервера, получать статус звонка и прерывать его по необходимости.

2) Какую библиотеку подключать?

Вот эту:

<dependency>
            <groupId>org.asteriskjava</groupId>
            <artifactId>asterisk-java</artifactId>
            <version>2.0.4</version>
</dependency>

3) Какие конфиги необходимо посмотреть на сервере?

extensions.conf — конфиг, который описывает диаплан. Вы к нему будете постоянно обращаться. Если более понятным языком, то там содержаться сценарии того, что будет делать сервер, при поступлении на него звонка на определенный номер. Сначала в диаплане ищется конкретный контекст — он записывается в квадратных скобочках, после этого под тегом этого контекста ищется номер, по которому вы обращаетесь.

manager.conf — конфиг с юзером и паролем к вашему серверу Asterisk

Содержание данного конфига должно быть примерно следующим:

[user_name]
    secret = password
    read = all
    write = all
    deny=0.0.0.0/0.0.0.0
    permit=0.0.0.0/255.255.255.0

  • user_name — имя пользователя
  • secret — пароль для него
  • deny — ip адреса, доступ которым запрещен под данным пользователем
  • permit — доступ которым разрешен. Обязательно указывайте ip, с которого обращаетесь, в permit, так как астер может отбить ваш запрос.

sip.conf — тут прописаны все транки. Транк — это телефон, с которого будем звонить клиенту.

4) С чего начинать писать код?

Тут два варианта: вам либо нужно совершать какие-то действия на сервере Asterisk, либо слушать события на сервере. Наша последовательность включает и то и другое.

Опишем план действий:

  1. Открываем конекшен к серверу;
  2. Описываем сценарий работы;
  3. Слушаем события;
  4. Закрываем конекшен.

Соответственно, соединение инициализируется во время создания объекта DefaultAsteriskServer:

import org.asteriskjava.live.AsteriskServer;
import org.asteriskjava.live.DefaultAsteriskServer;

AsteriskServer asteriskServer = new DefaultAsteriskServer(HOSTNAME, USERNAME, PASSWORD);
asteriskServer.initialize();

После того как открыли соединение, нам нужно позвонить пользователю. Назовем это сценарием действий. Описание сценария работы будет в отдельном классе:

/**
 * Задается сценарий прозвона
 */
public class ScenarioCall extends OriginateAction {

    private final Logger log = LoggerFactory.getLogger(ScenarioCall.class);

    private String TRUNK;
    private final String PHONE_FOR_RINGING;
    private final String EXTEN_FOR_APP;
    private final String CONTEXT_FOR_APP;
    
    public ScenarioCall(String trunk, String phoneForRinging, String extension, String context) {
        this.TRUNK = trunk;
        this.PHONE_FOR_RINGING = phoneForRinging;
        this.EXTEN_FOR_APP = extension;
        this.CONTEXT_FOR_APP = context;
        this.init();
    }

    /**
     * инициализируем сценарий и уже в конструкторе получаем готовый OriginateAction
     */
    private void init() {
        //номер абонента
        String callId = ValidValues.getValidCallId(this.PHONE_FOR_RINGING);
        //канал с которого звоним
        String channelAsterisk = ValidValues.getValidChannel(this.TRUNK, this.PHONE_FOR_RINGING);
        this.setContext(CONTEXT_FOR_APP);
        this.setExten(EXTEN_FOR_APP);
        this.setPriority(1);
        this.setAsync(true);
        this.setCallerId(callId);
        this.setChannel(channelAsterisk);
        log.info("Create Scenario Call: phone '{}',chanel '{}',context '{}',extension '{}'",
            callId,
            channelAsterisk,
            CONTEXT_FOR_APP,
            EXTEN_FOR_APP);
    }

}

Что нам нужно понимать в этом сценарии? Сначала создается соединение с транком. Транк — это номер с которого вы будете звонить абоненту. После этого создается соединение между транком и абонентом, уже после этого соединение между абонентом и еще кем там Вам нужно.

Именно в такой последовательности.

this.setContext(CONTEXT_FOR_APP) 
Передаваемое значение: контекст, в котором будем искать телефонный номер, с которым вы хотите связать абонента (из extensions.conf).

this.setExten(EXTEN_FOR_APP) 
Передаваемое значение: сценарий, который выполнится после того, как вы связались с абонентом (из extensions.conf).

this.setCallerId(callId) 
Передаваемое значение: номер нашего абонента

this.setChannel(channelAsterisk) 
Передаваемое значение: устанавливаемый канал связи, обычно выглядит так: trunk_name/phone_user.

Где искать trunk_name? На сервере астериск есть конфиг sip.conf — там прописаны все транки.

Создадим звонок:

if (asteriskServer .getManagerConnection().getState().equals(ManagerConnectionState.CONNECTED)
                    || asteriskServer .getManagerConnection().getState().equals(ManagerConnectionState.CONNECTING)
                    || asteriskServer .getManagerConnection().getState().equals(ManagerConnectionState.INITIAL)) {
                    try {
						ScenarioCall scenarioCall = new ScenarioCall(trank, phone, extension, context);
                        CallBack callBackForScenarioCall = new CallBack();
                        asteriskServer.originateAsync(scenarioCall, callBackForScenarioCall);
                    } catch (ManagerCommunicationException e) {
                        //при падении канала связи, StateConnection  может быть в RECONNECTING, а может вообще отвалиться 
                    }
                }

Мы создали звонок, но как за ним следить динамически?

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

Слушатель нужен, потому что класс CallBack не оповестит вас о окончании звонка, когда пользователь уже поговорил, а так же не оповестит вас о том, что пользователь мог еще куда бы то ни было перевестись.

/**
 * После того как вы передали в метод asteriskConnection.originateAsync экземпляр
 * класса CallBack - начнет исполняться сценарий на обзвон, переданный во втором параметре 
 * в originateAsync. CallBack будет служить своеобразным слушателем исполнения звонка,
 * то если если пользователь не возьмет трубку, будет вызван метод  onNoAnswer , если
 * линия будет занята то onBusy, если канал связи будет недоступен, то onFailure, и тд.
 * Там вы пишете обработку событий призошедших со звонком. Важно, что данный класс не оповестит
 * вас об успешном окончании звонка ( то есть когда пользователь взял трубку, поговорил и завершил звонок)
 */
public class CallBack implements OriginateCallback {
    
    /**
     * Поставим первоначальный статус в PRERING, потом при исполнении переопределенных методов класса 
     * OriginateCallback - можете его менять
     */
    private ChannelState resultCall = ChannelState.PRERING;

    /**
     * когда мы звоним абоненту, устанавливается этот статус. Сценарий на обзвон еще не закончил свое выполнение
     */
    @Override
    public void onDialing(AsteriskChannel asteriskChannel) {
        // канал связи создан, переустанавливаете resultCall, 
        // важно что asteriskChannel будет скорее всего null, 
        // так что устанавливать resultCall придется хардкодом
       // обработка события
    }

    /**
     * Абонент взял трубку. Сценарий на обзвон закончил исполнение
     * устанавливаем статус данного сценария в 6 - setStatus
     */
    @Override
    public void onSuccess(AsteriskChannel asteriskChannel) {
        // пользователь поднял трубку, asteriskChannel уже не null,
        // asteriskChannel.getState() будет скорее всего в значении ChannelState.UP
       // обработка события
    }

    /**
     * Аббонент не ответил или сбросил звонок, не подняв трубку
     * устанавливаем статус данного сценария в 7 - setStatus (рекомендуется)
     */
    @Override
    public void onNoAnswer(AsteriskChannel asteriskChannel) {
        // пользователь не ответил, 
        // важно что asteriskChannel будет скорее всего null, 
        // так что устанавливать resultCall придется хардкодом
       // обработка события
    }

    /**
     * Линия занята
     * устанавливаем статус данного сценария в 7 - setStatus (рекомендуется)
     */
    @Override
    public void onBusy(AsteriskChannel asteriskChannel) {
        // телефонная линия занята, 
        // важно что asteriskChannel будет скорее всего null,
        // так что устанавливать resultCall придется хардкодом
       // обработка события
    }

    /**
     * Произошла ошибка во время обзвона
     */
    @Override
    public void onFailure(LiveException e) {
        // обязательно проводите обработку данного события, 
        // потому что как показала практика, 
        // onFailure будет у вас очень часто

    }

}

Как повесить слушателя на Астериск?

Для этого нужно создать класс имплементирующий AsteriskServerListener, PropertyChangeListener.
Для созданного соединения посредством экземпляра класса AsteriskConnection осуществляем:

 this.asteriskConnection.addAsteriskServerListener(this.callBackEventListener);

this.callBackEventListener — экземпляр класса нашего слушателя, рождается из:

**
 * Слушатель для сервера Asterisk
 * имплементация PropertyChangeListener  нужна для того, чтобы слушать события с сервера.
 * имплементация AsteriskServerListener нужна для того, чтобы повесить слушателя на AsteriskConnection.
 */
public class CallBackEventListener implements AsteriskServerListener, PropertyChangeListener {


    public void onNewAsteriskChannel(AsteriskChannel channel) {
        channel.addPropertyChangeListener(this);
    }

    public void onNewMeetMeUser(MeetMeUser user) {
        user.addPropertyChangeListener(this);
    }

    public void onNewQueueEntry(AsteriskQueueEntry user) {
        user.addPropertyChangeListener(this);
    }

    public void onNewAgent(AsteriskAgent asteriskAgent) {
        asteriskAgent.addPropertyChangeListener(this);
    }

    /**
     * Ловит событие окончания звонка. С помощью {@link PropertyChangeEvent}
     * можно отслеживать любые события,
     * но в данном контексте необходимы только события окончания,
     * так как события начала звонка слушает класс CallBack
     *
     * @param propertyChangeEvent событие происходящее в течении звонка
     */
    public void propertyChange(PropertyChangeEvent propertyChangeEvent) {
        findEventEndCall(propertyChangeEvent);
    }

    private void findEventEndCall(PropertyChangeEvent event) {
        if (event.getSource() instanceof AsteriskChannel) {
            AsteriskChannel callBackChannel = (AsteriskChannel) event.getSource();
            String callId = getStringWithOnlyDigits(callBackChannel.getCallerId().toString());
            callId = ValidValues.getValidCallId(callId);
            if (callBackChannel.getState().toString().equals("HUNGUP")
                && event.getOldValue().toString().contains("RINGING")) {
                //пользователь не поднял трубку или сбросил
                callBackChannel.removePropertyChangeListener(this);
               // пишете обработку окончания звонка

            } else if (callBackChannel.getState().toString().equals("HUNGUP")
                && event.getOldValue().toString().contains("UP")) {
                //пользователь поднял трубку и поговорил
                callBackChannel.removePropertyChangeListener(this);
                // пишете обработку окончания звонка

            } else if (callBackChannel.getState().toString().equals("HUNGUP")) {
                // завершение звонка по другой причине
                callBackChannel.removePropertyChangeListener(this);
                // пишете обработку окончания звонка
            }
        }
    }

    private String getStringWithOnlyDigits(String strForParse) {
        String result = "";
        if (strForParse != null && !strForParse.isEmpty()) {
            CharMatcher ASCII_DIGITS = CharMatcher.anyOf("<>").precomputed();
            result = ASCII_DIGITS.removeFrom(strForParse.replaceAll("[^0-9?!]", ""));
        }
        return result;
    }
}

Советую в самом начале просто залогировать, то что приходит в propertyChange и посмотреть на PropertyChangeEvent, это будет адская портянка всего, что происходит на сервере. Он вообще никак не фильтрует информацию. Поэтому вывод: вешать слушателя надо как можно реже. Не на каждый звонок, потому что это можно сделать даже в классе OriginateCallback, насколько я находила. Это ни к чему. Посмотрите, какие вам приходят объекты PropertyChangeEvent, посмотрите какого типа там поля и какие из них вам нужны. Дальше — welcome в мир обработки информации.

Немного о валидации данных.

В OriginateAction.setChannel — передается trunk_name/phone_user
phone_user — если российский, то должен начинаться с восьмерки, если международный номер — с плюса.

В OriginateAction.setCallerId — передается номер телефона клиента,
потом в CallBackEventListener он придет в callBackChannel.getCallerId().

Будет брать его так:

String callId = getStringWithOnlyDigits(callBackChannel.getCallerId().toString());

В итоге не забываем про:

   
asteriskServer.shutdown();

Если вам нужно прервать какой-либо звонок, то либо в классе CallBackEventListener
на существующий канал связи выполняем:

callBackChannel.hangup();

Такой нехитрый получился туториал. С первого взгляда, конечно, очень просто, но поверьте, нужно много времени и нервов, чтобы найти информацию, отдебажить все методы и оставить работающие.

Успехов вам в работе с серверами Asterisk!

Дополнительная литература:

1) Asterisk-Java tutorial

2) Asterisk Managment Interface (AMI)

Комментарии (6)


  1. agic
    30.09.2019 17:11

    я просто оставлю ссылку и скажу, что пользоваться AMI в таком режиме не всегда есть гуд.


    1. helezpopova Автор
      30.09.2019 17:15

      так расскажите о недостатках) мне интересно ) Из прямых недостатков, которые я заметила, могу обозначить только то, что соединение может периодические падать и приходится ждать рекконекта.


      1. agic
        30.09.2019 17:22

        не так давно на «asterconf», было несколько докладов на эту тему, в том числе и мой доклад — «ARI или почему ami зло». если кратко ami — это телнет подключение которые управляет примитивами астериска, и самый минус, вам приходится полностью парсить все события которые валятся туда, ну не вам а либе… И при нагруженных системах это проблема, и таких минусов не мало в ami (хотя по сути он не плох). ARI это все таки RestFull интерфейс, с возможностью подписки на определенные события.


    1. pvsur
      30.09.2019 19:33

      ami — прошлый век. ari гораздо проще и понятнее. можно вообще без Астерикса, с помощью pjsip :) во всех случаях Ява — самый ненужный компонент ;)


    1. yaremchuk
      02.10.2019 14:03

      С ari4java имел проблемы из-за того, что она отказалась работать с некотрыми минорными версиями asterisk 13. Ругаясь на наличие/отсутствие какого-либо поля в сообщениях от сервера.
      Но так как ari хорошо документирован и с ним легко интегрироваться простыми http и websocket клиентами, мире оказалось проще и эффективней обойтись без ari4java.


  1. Gansterito
    03.10.2019 16:07

    А мне FastAGI за глаза хватает!