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:
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 guarddict
which determines strict constraints on event headers which must be matched exactly for theRouter
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 SIP407
response code. - The first 3 arguments to
reject_international
are required, namely,sess
,match
, androuter
and correspond to theSession
, re.MatchObject, andRouter
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))