#!/usr/bin/env python import sqlite3 import datetime import uuid import json import logging import concurrent.futures import asyncio import re import configparser import argparse import os.path import sys from operator import itemgetter import jwt import websockets from bottle import request, abort, Bottle, JSONPlugin, template, static_file, auth_basic from bottle_sqlite import SQLitePlugin import ari arg_parser = argparse.ArgumentParser(description='Manage the SIP Backend for the piphone') config = configparser.ConfigParser() arg_parser.add_argument('-c', '--config', help="Config file") args = arg_parser.parse_args() try: logging.debug("Let's use {} as a config file".format(args.config,)) config.read(args.config) except AttributeError as e: print(e) try: if os.path.isfile('config.ini'): logging.debug("Let's use config.ini as a config file") config.read('config.ini') elif os.path.isfile('/etc/piphone/sip_config.ini'): logging.debug("Let's use /etc/iphone/config.ini as a config file") config.read('/etc/piphone/sip_config.ini') else: raise Exception("No configuration file found (tried ./config.ini and /etc/piphone/sip_config.ini") except Exception as e: arg_parser.print_help() sys.exit(1) application = app = Bottle(autojson=False) app.install(SQLitePlugin(dbfile=config['piphone']['db'])) app.install(JSONPlugin(json_dumps=lambda s: json.dumps(s, cls=PiphoneJSONEncoder))) threads = concurrent.futures.ThreadPoolExecutor(max_workers=5) loop = asyncio.get_event_loop() running = False ws = None # Loggers handler = logging.FileHandler(config['piphone']['log']) verbosity = getattr(logging, config['piphone']['verbosity'].upper()) or logging.DEBUG phone_logger = logging.getLogger('piphone') phone_logger.addHandler(handler) phone_logger.setLevel(verbosity) ws_logger = logging.getLogger('asterisk') ws_logger.addHandler(handler) ws_logger.setLevel(verbosity) bottle_logger = logging.getLogger('bottle') bottle_logger.addHandler(handler) bottle_logger.setLevel(verbosity) class PiphoneJSONEncoder(json.JSONEncoder): def default(self, obj): """ We need to implement this to be able to JSONEncode """ if isinstance(obj, Call): return { 'caller': obj.caller , 'callee': obj.callee , 'callid': obj.id , 'url': obj.url , 'history': obj.history , 'owner': obj.owner } else: return json.JSONEncoder.default(self, obj) def authenticated(f): ''' We need a decorator to check if our query is authenticated. We will store an API key and SECRET in ur database, the client needs to have both of them. He must then send us a JWT token with an API claim in the payload. The JWT token must be encoded and signed with the SECRET. If the token is bad, we return a 403. ''' def wrapped(db, *args, **kwargs): # Let's get the JWT token. It should be a params (from get or post or whatev') bottle_logger.debug("Authentication: {}".format([':'.join([key, request.params[key]]) for key in request.params],)) if 'token' not in request.params: bottle_logger.error("No token found in the params") abort(401, "No token found in the query") # We want the api id in the params to. if 'api' not in request.params: bottle_logger.error("No api id found in the params") abort(401, "No api id found in the params") # Now, let's get the token on our side try: results = db.execute('SELECT token FROM users WHERE api = ?', (request.params['api'],)).fetchall() assert len(results) == 1 token = results[0][0] auth_token = jwt.decode(request.params['token'], token) assert auth_token['api'] == request.params['api'] for key in auth_token: request.params[key] = auth_token[key] except (jwt.exceptions.InvalidTokenError, AssertionError) as e: bottle_logger.error("Access refused") bottle_logger.exception(e) abort(403, e) except Exception as e: bottle_logger.exception(e) abort(500, e) return f(db, *args, **kwargs) return wrapped def connected(f): ''' This is a decorator used to check if we are connected to a websocket or not. If we're not We're returning 500 error. ''' def wrapped(db, *args, **kwargs): if isinstance(ws, websockets.client.WebSocketClientProtocol): if ws.open == True: return f(db, *args, **kwargs) ws_logger.error("Websocket connection is closed") abort(500, "Websocket isn't running") return wrapped def sanitize_phonenumber(number): """ This function is used to sanitize a phone number. If it starts with a +, it will be removed and replaced by 00. Any chars who do notbelong to [0-9] will be stripped. If the number doesn't starts with 00, a TypeError will be raised """ if number[0] == '+': number = '00' + number[1:] number = ''.join([c for c in number if c in '0123456789']) if not number.startswith('00'): raise TypeError('{} is not a valid international number, it should start with 00') return number @asyncio.coroutine def listen(db): ''' Start listening on the websocket ''' global running global ws ws_logger.debug('Connecting to websocket: {}'.format(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password']))) ws = yield from websockets.connect(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format( config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password'])) ws_logger.debug('Websocket connected: {}'.format(type(ws))) while running == True: try: event = yield from ws.recv() # Let's call the applications function yield from dispatch(json.loads(event), db) except websockets.exceptions.ConnectionClosed as e: ws_logger.warning("Connexion closed") ws_logger.exception(e) running = False ws.close() return except Exception as e: ws_logger.exception(e) continue ws.close() @asyncio.coroutine def dispatch(event, db): """ Let's work on our events. Parse them and do request on the ARI API. Event is a dict loaded from JSON. """ ws_logger.debug('Event received: {}'.format(event,)) # Let's get the call ID, the call id isthe channel id minus the last -part. if 'channel' not in event: return call_id = re.sub('-\d+$', '', event['channel']['id']) call = Call.load(call_id, db) call.event_handler(event) class Call(object): """ This Class is used to manage operations on a call, to print it and dump it. """ history = [] actions = {'Created': 'call_caller' , 'ChannelStateChange': 'change' , 'ChannelDtmfReceived': 'dtmf' , 'ChannelDestroyed': 'hangup' , 'ChannelHangupRequest': 'hangup'} def __init__(self, caller, callee, owner, callid=None, db=None): try: self.caller = caller self.callee = callee self.owner = owner if callid == None: self.id = str(uuid.uuid4()) else: self.id = callid self.db = db except Exception as e: phone_logger.exception(e) raise e @property def url(self): return ''.join(['/calls/', self.id]) @property def state(self): sort = sorted(self.history, reverse=True, key=itemgetter(1)) return sort[0][0] @classmethod def load(cls, callid, db): phone_logger.debug("Loading call {} from db {}".format(callid, db,)) try: results = db.execute('SELECT caller, callee, owner, callid, history FROM calls WHERE callid = ?;', (callid,)) result = results.fetchone() assert len(result) == 5 object = cls(result[0], result[1], result[2], result[3], db=db) object.history = json.loads(result[4]) return object except Exception as e: phone_logger.exception(e) raise e def update(self, new_state): ''' Let's update the state of the call. new_state is a tuple in the form (newstate, timestamp,) ''' phone_logger.debug("Got a new state: {}".format(new_state,)) self.history.append(new_state) self.save() def event_handler(self, event): ''' There's a new event related to our call ''' state = event['type'] if state in self.actions: getattr(self, self.actions[state])(event=event) def hangup(self, event): ''' There's a call which has lost connection. Probably someone who hanged up the call. We need to check if the other side is still up, which can be done by checking the bridge item (it is in the channels part of the bridge object) and see if there's more than one channel. If there's more than one, then we need to send a hangup to the other side and then delete our channel, if not we need to delete ourselves and then delete the bridge. We might also be in a case where no channel has been created … or we're still on moh. ''' bridge_id = '-'.join(event['channel']['id'].split('-')[:-1]) self.update((':'.join([event['channel']['state'] , event['channel']['id'].split('-')[-1]]) , event['timestamp'])) try: bridge = ari.Bridge(config['asterisk'], bridge_id, 'mixed') results = json.loads(bridge.status()) except: # Not in a bridge yet. Our channel has been destroyed. # That or we're in moh. phone_logger.info('Channel destroyed {}'.format(event['channel']['id'])) return if len(results['channels']) == 0: # We were the last channel standing phone_logger.info('Deleting bridge {}'.format(bridge_id)) bridge.delete() else: # There's at least one channel standing for channel in results['channels']: chan = ari.Channel(config['asterisk'], channel) phone_logger.info('Hanging up channel {}'.format(channel)) chan.delete() def dtmf(self, event): ''' We received a DTMF sequence ''' try: assert self.state.startswith('Up') # The only thing we want to do is to call the callee if we press 1 if event['digit'] != '1': return # Now, we're connectig the other side # We need to originate a call to the other side phone_logger.info('Will now connect {} to {}'.format(self.caller, self.callee,)) endpoint = 'SIP/' + sanitize_phonenumber(self.callee) + '@' + config['asterisk']['sip-context'] channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.callee)) channel.originate(endpoint) except AssertionError as e: phone_logger.error("Received a DTMF sequence out le being in a '{}' state, ignoring: {}".format(self.state, event['digit'])) def change(self, event): ''' Let's change the state of the call ''' # First we need to check if it's really a change ie, if the new state is not the previous one self.update((':'.join([event['channel']['state'] , event['channel']['id'].split('-')[-1]]) , event['timestamp'],)) phone_logger.info("New state for call {}: {}".format(event['channel']['id'], event['channel']['state'])) # We now need to take action according to our new state if event['channel']['state'] == 'Up': # Are we the caller or the callee? if event['channel']['id'].endswith(sanitize_phonenumber(self.callee)): # We are the callee # Step 1 create a bridge bridge = ari.Bridge(config['asterisk'], self.id, 'mixing') phone_logger.debug("Creating a bridges to connect {} to {}".format(self.caller, self.callee,)) try: bridge.create() except Exception as e: raise e # Step 2, stop playing moh to the channel channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.caller)) channel.stopMoh() # Step 3, connect everyone channels = ",".join([self.id + '-' + sanitize_phonenumber(self.caller), event['channel']['id']]) phone_logger.debug("Moving channels ({}) to the created bridge: {}".format(channels, bridge.name,)) bridge.addChannel(channels) phone_logger.info("Call now fully connected: {} <-> {}".format(self.caller, self.callee)) return # Call is being picked up, we want to play a song try: channel = ari.Channel(config['asterisk'], event['channel']['id']) channel.startMoh(config['moh']['class']) except Exception as e: phone_logger.exception(e) raise e def call_caller(self, event): ''' Let's call the caller. It's a simple originate. We will also check if the MoH bridge is ready, because it will be used to store people in it waiting for the callee to pick up the phone. The bridge needed to connect the calls together will be created later. ''' self.update((':'.join([event['type'] , event['channel']['id'].split('-')[-1]]) , event['timestamp'],)) # Now, let's create the channel try: endpoint = 'SIP/' + sanitize_phonenumber(self.caller) + '@' + config['asterisk']['sip-context'] channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.caller)) channel.originate(endpoint) except Exception as e: phone_logger.exception(e) raise e def save(self): ''' Save the Call to database. ''' phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder))) try: self.db.execute('''INSERT OR REPLACE INTO calls (caller, callee, owner, callid, history) VALUES (?, ?, ?, ?, ?) ''' , (self.caller, self.callee, self.owner, self.id, json.dumps(self.history))) self.db.commit() except Exception as e: bottle_logger.exception(e) phone_logger.exception(e) raise e def start(db): global running running = True threads.submit(app.run, server='paste') loop.run_until_complete(listen(db)) def stop(): global running running = False ws.close() loop.close() threads.shutdown(wait=False) sys.exit(0) @app.get('/calls/') @app.get('/calls/') @authenticated @connected def calls(db, callid=None): """ Return the list of calls associated to the connected user. The call has a status, caller, callee and history (status change+timestamp) """ bottle_logger.debug("GET {}".format(request.fullpath)) if callid == None: try: results = db.execute('SELECT callid FROM calls WHERE owner = ?;', (request.params['api'],)) calls = [] for row in results.fetchall(): call = Call.load(row[0], db) bottle_logger.debug("Call fetched: {}".format(json.dumps(call, cls=PiphoneJSONEncoder))) calls.append(call) head = {'call': request.fullpath, 'user': request.params['api'], 'hits': len(calls)} return {'head': head, 'data': calls} except Exception as e: bottle_logger.exception(e) abort(500, "Exception") # We first need to check if we can access the callid we asked for try: results = db.execute('SELECT callid FROM calls WHERE owner = ? AND callid = ? ;' , (request.params['api'], callid,)) rows = results.fetchall() bottle_logger.debug("Found {} results: {}".format(len(rows), rows)) assert len(rows) == 1 call = Call.load(callid, db) head = {'call': call.url, 'user': request.params['api'], 'hits': 1} return {'head': head, 'data': call} except AssertionError as e: bottle_logger.debug("Not exactly one results found, this is an issue") bottle_logger.error("Unauthorized access to call {} from user {}".format(callid, request.params['api'])) abort(403, "You do not have the authorization to get this call") except Exception as e: bottle_logger.debug("Exception catched: {}".format(e,)) bottle_logger.error("Call not found {}".format(callid,)) abort(404, "This call does not exist") @app.post('/calls/') @app.post('/calls/') @authenticated @connected def originate(db, callid=None): bottle_logger.debug("POST {}".format(request.fullpath)) try: if callid is not None: call = Call(request.params['caller'], request.params['callee'], request.params['api'], callid=callid, db=db) else: call = Call(request.params['caller'], request.params['callee'], request.params['api'], db=db) bottle_logger.debug("Originate a call: {}".format(json.dumps(call, cls=PiphoneJSONEncoder))) call.event_handler({'type': 'Created', 'timestamp': datetime.datetime.now().isoformat(), 'channel': {'id': 'Init'}}) call.save() head = {'call': call.url , 'user': request.params['api'] , 'hits': 1} return {'header': head, 'data': call} except Exception as e: bottle_logger.debug("Missing params : {}".format([p for p in request.params],)) bottle_logger.exception(e) abort(400, "Missing or incorrect fields, the call cannot be processed") @app.get('/static/') def static_files(filepath): """ take care of static files. should use apache/nginx instead. """ return static_file(filepath, root='./views') def login_admin(user, password): user = db.execute('SELECT api, token, admin FROM users where api = ?', user).fetchone() if user is None: # user does not exist return False if password != user[1]: # password does not match return False if user[2] == 0: # User is not admin return False return True @app.get('/admin') @auth_basic(login_admin) def little_admin(db): # We need to check if we're admin users = db.execute('SELECT api, token, admin FROM users').fetchall() return template('index', users=users, token=request.params['token']) @app.post('/admin') @auth_basic(login_admin) def medium_admin(db): api = request.forms.get('api') token = request.forms.get('api_token') admin = request.forms.get('admin') action = request.forms.get('action') if action == 'delete': db.execute("DELETE FROM users WHERE api = ?", (api, )) db.commit() elif action == 'add': db.execute("INSERT INTO users (api, token, admin) VALUES (?, ?, ?)", (api, token, admin)) db.commit() elif action == 'update': db.execute("UPDATE users set token = ?, admin = ? where api = ?", (token, admin, api )) db.commit() users = db.execute('SELECT api, token, admin FROM users').fetchall() return template('index', users=users) if __name__ == '__main__': db = sqlite3.connect(config['piphone']['db']) phone_logger.info("Starting the piphone SIP backend") try: start(db) except (KeyboardInterrupt, SystemExit): stop()