Building a cluster service

switchio supports building full fledged routing systems just like you can with FreeSWITCH’s XML dialplan but with the added benefit that you can use a centralized “dialplan” to control a FreeSWITCH process cluster.

This means call control logic can reside in one (or more) switchio process(es) running on a separate server allowing you to separate the brains and logic from the muscle and functionality when designing a scalable FreeSWITCH service system.

A service is very easy to create given a set of deployed Freeswitch processes:

from switchio import Service, event_callback

class Proxier(object):
    """Proxy all inbound calls to the destination specified in the SIP
    Request-URI.
    """
    @event_callback('CHANNEL_PARK')
    def on_park(self, sess):
        if sess.is_inbound():
            sess.bridge(dest_url="${sip_req_uri}")

s = Service(['FS_host1.com', 'FS_host2.com', 'FS_host3.com'])
s.apps.load_app(Proxier, app_id='default')
s.run()  # blocks forever

In this example all three of our FreeSWITCH servers load a Proxier app which simply bridges calls to the destination requested in the SIP Request-URI header. The app_id=’default’ kwarg is required to tell the internal event loop that this app should be used as the default (i.e. when no other app has consumed the event/session for processing).

Launching a service

You can launch switchio services using the cli client. Simply specify the list of FreeSWITCH hosts to connect to and specify the desired app(s) which should be loaded using (multiples of) the --app option:

switchio serve freeswitch1.net freeswitch2.net --app switchio.apps.routers:Proxier

This runs the example from above. You can also load apps from arbitrary Python modules you’ve written:

switchio serve freeswitch1.net freeswitch2.net --app ./path/to/dialplan.py:router

Note

The name specified after the ':' is the attribute that will be getattr-ed on the module. You can specify --loglevel for detailed logging.

Flask-like routing

Using the Router app we can define a routing system reminiscent of flask.

Let’s start with an example of blocking certain codes:

dialplan.py
from switchio.apps.routers import Router

router = Router(guards={
    'Call-Direction': 'inbound',
    'variable_sofia_profile': 'external'})

@router.route('00(.*)|011(.*)', response='407')
def reject_international(sess, match, router, response):
    sess.respond(response)
    sess.hangup()

There’s a few things going on here:

  • A Router is created with a guard dict which determines strict constraints on event headers which must be matched exactly for the Router to invoke registered (via @route) functions.
  • We decorate a function, reject_international, which registers it to be invoked whenever an international number is dialed and will block such numbers with a SIP 407 response code.
  • The first 3 arguments to reject_international are required, namely, sess, match, and router and correspond to the Session, re.MatchObject, and Router respectively.

In summmary, we can define patterns which must be matched against event headers before a particular route function will be invoked.

The signature for Router.route which comes from PatternCaller is:

@route(pattern, field=None, kwargs)

and works by taking in a regex pattern, an optional field (default is 'Caller-Destination-Number') and kwargs. The pattern must be matched against the field event header in order for the route to be called with kwargs (i.e. reject_international(**kwargs)).

You can run this app directly using switchio serve:

switchio serve freeswitch1.net freeswitch2.net --app ./dialplan.py:router

Note

In the case above this is the Router instance which has route function (reject_international) already registered.

Let’s extend our example to include some routes which bridge differently based on the default 'Caller-Destination-Number' event header:

import switchio
from switchio.apps.routers import Router

router = Router(guards={
    'Call-Direction': 'inbound',
    'variable_sofia_profile': 'external'})

@router.route('00(.*)|011(.*)', response='407')
@router.route('1(.*)', gateway='long_distance_trunk')
@router.route('2[1-9]{3}$', out_profile='internal', proxy='salespbx.com')
@router.route('4[1-9]{3}$', out_profile='internal', proxy='supportpbx.com')
async def trunking_dp(sess, match, router, out_profile=None, gateway=None,
                      proxy=None, response=None):
    if response:
        sess.log.warn("Rejecting call to {}".format(
            sess['Caller-Destination-Number']))
        sess.respond(response)
        sess.hangup()
        await sess.recv("CHANNEL_HANGUP")
    else:
        dest = sess['variable_sip_req_uri']
        sess.log.info("Bridging to {}".format(dest))

        sess.bridge(
            # bridge back out the same profile if not specified
            # (the default action taken by bridge)
            profile=out_profile,
            gateway=gateway,
            # always use the SIP Request-URI
            dest_url=dest,
            proxy=proxy,
        )
        try:
            # suspend and wait up to a min for call to be answered
            await sess.recv('CHANNEL_ANSWER', timeout=60)
        except TimeoutError:
            # play unreachable dest message
            sess.answer()
            await sess.recv('CHANNEL_ANSWER')
            await asyncio.sleep(1)
            sess.playback('misc/invalid_extension.wav')
            await sess.recv("PLAYBACK_STOP")
            sess.hangup()
            await sess.recv("CHANNEL_HANGUP")

if __name__ ==  '__main__':
    s = switchio.Service(['FS_host1.com', 'FS_host2.com', 'FS_host3.com'])
    s.apps.load_app(router, app_id='default')
    s.run()  # blocks forever

Note

You can also use the cli client run directly:

$ switchio serve FS_host1.com FS_host2.com FS_host3.com --app ./dialplan.py:router

Which defines that:

  • all international calls will be blocked.
  • any inbound calls prefixed with 1 will be bridged to our long distance provider.
  • all 2xxx dialed numbers will be directed to the sales PBX.
  • all 4xxx dialed numbers will be directed to the support PBX.

Notice that we can parameterize the inputs to the routing function using kwargs. This lets you specify data inputs you’d like used when a particular field matches. If not provided, sensible defaults can be specified in the function signature.

Also note that the idea of transferring to a context becomes a simple coroutine call:

@router.route("^(XXXxxxxxxx)$")
def test_did(sess, match, router):
    # call our route function from above
    return await bridge2dest(sess, match, router, profile='external')

Just as before, we can run our router as a service and use a single “dialplan” for all nodes in our FreeSWITCH cluster:

s = Service(['FS_host1.com', 'FS_host2.com', 'FS_host3.com'])
s.apps.load_app(router, app_id='default')
s.run()  # blocks forever

Note

If you’d like to try out switchio routes alongside your existing XML dialplan (assuming you’ve added the park only context in your existing config) you can either pass in {"Caller-Context": "switchio"} as a guard or you can load the router with:

s.apps.load_app(router, app_id='switchio', header='Caller-Context')

Replicating XML dialplan features

The main difference with using switchio for call control is that everything is processed at runtime as opposed to having separate parse and execute phases.

Retrieving Variables

Accessing variable values from FreeSWITCH is already built into switchio’s Session API using traditional getitem access.

Basic Logic

As a first note, you can accomplish any “logical” field pattern match either directly in Python or by the regex expression to Router.route:

Here is the equivalent of the logical AND example:

from datetime import datetime

@router.route('^500$')
def on_sunday(sess, match, router, profile='internal', did='500'):
    """On Sunday no one works in support...
    """
    did = '531' if datetime.today().weekday() == 6 else did
    sess.bridge('{}@example.com'.format(did), profile=profile)

And the same for logical OR example:

import re

# by regex
@router.route('^500$|^502$')
def either_ext(sess, match, router):
    sess.answer()
    sess.playback('ivr/ivr-welcome_to_freeswitch.wav')

# by if statement
@router.route('^.*$')
def match(sess, match, router):
    if re.match("^Michael\s*S?\s*Collins", sess['variable_caller_id_name']) or\
            re.match("^1001|3757|2816$", sess['variable_caller_id_number']):
        sess.playback("ivr/ivr-dude_you_rock.wav")
    else:
        sess.playback("ivr/ivr-dude_you_suck.wav")

Nesting logic

Nested conditions Can be easily accomplished using plain old if statements:

@router.route('^1.*(\d)$')
def play_wavfile(sess, match, router):
    # get the last digit
    last_digit = match.groups()[0]

    # only play the extra file when last digit is '3'
    if last_digit == '3':
        sess.playback('foo.wav')

    # always played if the first digit is '1'
    sess.playback('bar.wav')

Break on true

Halting all further route execution (known as break on true) can be done by raising a special error:

@router.route('^1.*(\d)$')
def play_wavfile(sess, match, router):
    sess.playback('foo.wav')

    if not sess['Caller-Destination-Number'] == "1100":
        raise router.StopRouting  # stop all further routing

Record a random sampling of call center agents

Here’s an example of randomly recording call-center agents who block their outbound CID:

import random

@router.route('^\*67(\d+)$')
def block_cid(sess, match, router):
    did = match.groups()[0]

    if sess.is_outbound():
        # mask CID
        sess.broadcast('privacy::full')
        sess.setvars({'privacy': 'yes', 'sip_h_Privacy': 'id'})

        if random.randint(1, 6) == 4:
            sess.log.debug("recording a sneaky agent to /tmp/agents/")
            sess.start_record('/tmp/agents/{}_to_{}.wav'.format(sess.uuid, did))