Asterisk / WAZO - Choisir un trunk en fonction d’un script AGI en Python

But

Nous allons voir comment rediriger un appel sortant vers un trunk ou un autre en fonction d’un script écrit en python. L’intérêt est de pouvoir utiliser préférentiellement un trunk pour les appels mobiles, par exemple, dans la limite d’un forfait et ensuite basculer sur un autre trunk une fois le forfait épuisé.

Typiquement, les illimités liés aux offres internet sont limitées à 99 numéros appelés par mois et la tarification au delà est moins intéressante que celle d’un opérateur classique de VOIP.

Prérequis

L’installation présentée ici est faite avec http://wazo.community/ mais elle fonctionne tout aussi bien avec https://www.xivo.solutions/ ou Asterisk

Nous aurons besoin de python et de la librairie pyst2 qui s’installe via :

pip install pyst2

Le script python

Voici le script écrit en python. Il récupère en argument le numéro appelé et en fonction de celui-ci et des limites définies pour les différents trunks, il détermine quel trunk utiliser et le retourne à Wazo via la variable BEST_TRUNK.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

import sys
import sqlite3

# requirements : pyst2
from asterisk import agi

# save script as : /var/lib/asterisk/agi-bin/choose_optimal_trunk.py

# mkdir /var/local/wazo/
# chown asterisk /var/local/wazo/
DATABASE = '/var/local/wazo/choose_optimal_trunk.db'

_trunks = {
    'freephonie': {
        'priority': 1,
        'max_calls': 99,
    },
    'trunk_principal': {
        'priority': 2,
        'max_calls': None,
    },
}


class Store:
    def __init__(self):
        """
        Create db used for storage if not exist
        """
        self.conn = sqlite3.connect(DATABASE)
        self.conn.cursor().execute(
            (
                "CREATE TABLE IF NOT EXISTS calls_log ("
                + "number text PRIMARY KEY, "
                + "trunk text NOT NULL, "
                + "date datetime DEFAULT current_timestamp, "
                + "nbcalls integer DEFAULT 0);"
            )
        )
        self.conn.commit()

    def find_exisiting_trunk(self, destination):
        """
        Search for previous call on same number
        --
        returns trunk name if destination was already seen, None otherwise
        """
        result = self.conn.cursor().execute(
            'SELECT trunk from calls_log where number="{}"'.format(destination)
        ).fetchall()
        if len(result) > 0:
            return result[0][0]
        return None

    def count_calls_for_trunk(self, trunk):
        """
        Count calls to different numbers made to this trunk
        """
        result = self.conn.cursor().execute(
            'SELECT count(number) from calls_log where trunk="{}"'.format(trunk)
        ).fetchall()
        return result[0][0] or 0

    def set_stats_for_destination(self, destination, trunk):
        """
        Store choosen trunk for the destination or update counter nbcalls
        """
        self.conn.cursor().execute(
            (
                'INSERT OR IGNORE INTO calls_log (nbcalls, trunk, number) ' +
                'values (0, "{0}", "{1}");'
            ).format(trunk, destination)
        )
        self.conn.cursor().execute(
            (
                'UPDATE calls_log set ' +
                'nbcalls=(select nbcalls from calls_log where number="{1}")+1 ' +
                'where number="{1}" and trunk="{0}";'
            ).format(trunk, destination)
        )
        self.conn.commit()
        return


class ChooseOptimalTrunk:
    """
    Object responsible of choosing the best trunk
    to use according to rules of priority and max_calls per trunk
    It stores choosen trunk for a given destination in sqlite database
    """
    def __init__(self):
        """
        Init object
        """
        # Init store
        self.store = Store()
        return

    def get_optimal_trunk(self, destination):
        """
        return prefered trunk as a string and store stats in db
        """

        trunk = self.store.find_exisiting_trunk()
        if trunk:
            self.__set_stats_for_destination(destination, trunk)
            return trunk

        else:
            # Sort trunk by priority order
            prioritized_trunks = sorted(
                [(x, _trunks[x]['priority']) for x in _trunks],
                key=lambda tup: tup[1]
            )
            for t, priority in prioritized_trunks:
                cft = self.store.count_calls_for_trunk(t)
                # Return first matching trunk
                if _trunks[t]['max_calls'] is None or cft < _trunks[t]['max_calls']:
                    self.store.set_stats_for_destination(destination, t)
                    return t


# init AGI interface
myagi = agi.AGI()
myagi.verbose('script choose_optimal_trunk started')

# Get callerid and destination 
caller = myagi.env['agi_callerid']
destination = myagi.env['agi_arg_1']
myagi.verbose("call from {0} to {1}".format(caller, destination))


# Determine best trunk
cot = ChooseOptimalTrunk()
best_trunk = cot.get_optimal_trunk(destination=destination)
myagi.verbose("call from {0} to {1} should use {2}".format(caller, destination, best_trunk))

# Return trunk
myagi.set_variable('BEST_TRUNK', best_trunk)

myagi.verbose('script choose_optimal_trunk ended')
sys.exit()

Vous pouvez télécharger le script choose_optimal_trunk.py.

Le fonctionnement général est le suivant :

  • les trunks sont définis dans le dictionnaire _trunk. Le paramètre max_calls permet de définir le nombre d’appels maximums sur un trunk donné sur la période.
  • lors d’un appel, le script vérifie si le numéro à déjà été appelé.

    • Si oui, il renvoie le même trunk que celui qui avait été proposé la fois précédente.
    • Dans le cas contraire, il classe les trunks par ordre de priorité et retourne le premier qui n’a pas atteint la limite du nombre d’appel et enregistre le couple numéro/trunk pour la prochaine fois.

Mise en place

Enregistrer le fichier sur le serveur Wazo sous le nom /var/lib/asterisk/agi-bin/choose_optimal_trunk.py et créer le répertoire où sera stocké la base de données sqlite.

mkdir /var/local/wazo/
chown asterisk /var/local/wazo/

Configuration de Wazo

Pour intégrer le script dans Wazo, il faut créer une entrée dans le dialplan. Aller dans « Configuration IPBX / Fichiers de configuration » et ajouter un fichier :

[choose_optimal_trunk]
exten => _33[67]XXXXXXXX,1,AGI(/var/lib/asterisk/agi-bin/choose_optimal_trunk.py,${EXTEN}) 
same => n, Dial(SIP/${BEST_TRUNK}/${EXTEN})
same => n, Playback(congestion-call)
same => n, Hangup()

_33[67]XXXXXXXX permet de faire passer tous les appels vers les numéros commençant par 336 ou 337 par le script.

Il faut ensuite définir une interconnexion personnalisée. Aller dans « Gestion des interconnexions / Personnalisée »  :

  • Nom : optimal_trunk
  • Interface : local
  • Suffixe de l’interface : @choose_optimal_trunk
  • Contexte : Appels sortants

Il ne reste plus qu’à utiliser ce trunk local pour passer les appels sortant. Aller dans « Gestion des appels / Appels sortants » et pour le contexte to-extern, choisir optimal_trunk comme interconnexion première et garder le trunk principal en second choix.

Et voilà, c’est tout bon

Debug

Pour voir ce qui se passe dans asterisk, il suffit de se connecter à la console CLI :

asterisk -r

Entrer la ligne agi set debug on. En passant un appel, on voit ce qui se passe, comme par exemple :

[Aug 18 17:22:40]     -- Executing [dial@outcall:7] Dial("SIP/qy69qfr4-000000ca", "local/33683271234@choose_optimal_trunk,,o(0683271234)") in new stack
[Aug 18 17:22:40]     -- Called local/33683271234@choose_optimal_trunk
[Aug 18 17:22:40]     -- Executing [33683271234@choose_optimal_trunk:1] AGI("Local/33683271234@choose_optimal_trunk-0000001a;2", "/var/lib/asterisk/agi-bin/choose_optimal_trunk.py,33683271234") in new stack
[Aug 18 17:22:40]     -- Launched AGI Script /var/lib/asterisk/agi-bin/choose_optimal_trunk.py
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_request: /var/lib/asterisk/agi-bin/choose_optimal_trunk.py
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_channel: Local/33683271234@choose_optimal_trunk-0000001a;2
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_language: en
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_type: Local
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_uniqueid: 1503069760.256
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_version: 14.6.0
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_callerid: 33437471234
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_calleridname: 33437471234
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_callingpres: 0
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_callingani2: 0
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_callington: 0
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_callingtns: 0
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_dnid: unknown
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_rdnis: unknown
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_context: choose_optimal_trunk
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_extension: 33683271234
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_priority: 1
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_enhanced: 0.0
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_accountcode:
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_threadid: 139852391528192
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> agi_arg_1: 33683271234
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >>
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Rx << VERBOSE "script choose_optimal_trunk started" 1
[Aug 18 17:22:40]  /var/lib/asterisk/agi-bin/choose_optimal_trunk.py,33683271234: script choose_optimal_trunk started
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> 200 result=1
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Rx << VERBOSE "call from 33437479697 to 33683271234" 1
[Aug 18 17:22:40]  /var/lib/asterisk/agi-bin/choose_optimal_trunk.py,33683271234: call from 33437479697 to 33683271234
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> 200 result=1
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Rx << VERBOSE "call from 33437471234 to 33683271234 should use freephonie" 1
[Aug 18 17:22:40]  /var/lib/asterisk/agi-bin/choose_optimal_trunk.py,33683271234: call from 33437479697 to 33683271234 should use PAT_HESPUL_1FXO_1
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> 200 result=1
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Rx << SET VARIABLE "BEST_TRUNK" "freephonie"
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> 200 result=1
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Rx << VERBOSE "script choose_optimal_trunk ended" 1
[Aug 18 17:22:40]  /var/lib/asterisk/agi-bin/choose_optimal_trunk.py,33683271234: script choose_optimal_trunk ended
[Aug 18 17:22:40] <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Tx >> 200 result=1
[Aug 18 17:22:40]     -- <Local/33683271234@choose_optimal_trunk-0000001a;2>AGI Script /var/lib/asterisk/agi-bin/choose_optimal_trunk.py completed, returning 0
[Aug 18 17:22:40]     -- Executing [33683271234@choose_optimal_trunk:2] Dial("Local/33683271234@choose_optimal_trunk-0000001a;2", "SIP/freephonie/33683271234") in new stack