piphone.py 18.8 KB
Newer Older
1 2 3
#!/usr/bin/env python

import sqlite3
okhin's avatar
okhin committed
4 5 6
import datetime
import uuid
import json
7 8 9
import logging
import concurrent.futures
import asyncio
10
import re
11 12
import configparser
import argparse
13 14
import os.path
import sys
15
from operator import itemgetter
16 17

import jwt
18
import websockets
19 20
from bottle import request, abort, Bottle, JSONPlugin
from bottle_sqlite import SQLitePlugin
21 22

import ari
23

24
arg_parser = argparse.ArgumentParser(description='Manage the SIP Backend for the piphone')
25
config = configparser.ConfigParser()
26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45

arg_parser.add_argument('-c', '--config', help="Config file")
arg_parser.parse_args()
try:
    logging.debug("Let's use {} as a config file".config(arg_parser.config,))
    config.read(arg_parser.config)
except AttributeError:
    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/config.ini'):
            logging.debug("Let's use /etc/iphone/config.ini as a config file")
            config.read('/etc/piphone/config.ini')
        else:
            raise Exception("No configuration file found (tried ./config.ini and /etc/piphone/config.ini")
    except Exception as e:
        arg_parser.print_help()
        sys.exit(1)

46
application = app = Bottle(autojson=False)
47
app.install(SQLitePlugin(dbfile=config['piphone']['db']))
48 49 50 51
app.install(JSONPlugin(json_dumps=lambda s: json.dumps(s, cls=PiphoneJSONEncoder)))

threads = concurrent.futures.ThreadPoolExecutor()
loop = asyncio.get_event_loop()
52

53 54 55 56
running = False
ws = None

# Loggers
57 58
handler = logging.FileHandler(config['piphone']['log'])
verbosity = getattr(logging, config['piphone']['verbosity'].upper()) or logging.DEBUG
59 60
phone_logger = logging.getLogger('piphone')
phone_logger.addHandler(handler)
61
phone_logger.setLevel(verbosity)
62 63
ws_logger = logging.getLogger('asterisk')
ws_logger.addHandler(handler)
64
ws_logger.setLevel(verbosity)
65 66
bottle_logger = logging.getLogger('bottle')
bottle_logger.addHandler(handler)
67
bottle_logger.setLevel(verbosity)
68 69 70 71 72 73 74 75 76 77 78 79 80 81

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:
82
            return json.JSONEncoder.default(self, obj)
83

84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133
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:
            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

okhin's avatar
okhin committed
134 135 136 137 138 139 140 141 142 143 144 145 146 147
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

148 149 150 151 152 153
async def hold_bridge():
    '''
    We will create a bridge that will be used to park caller waiting for callee to join.
    It will play a song in a loop.
    '''
    try:
okhin's avatar
okhin committed
154
        bridge = ari.Bridge(config['asterisk'], config['moh']['name'], 'holding')
155 156
    except Exception as e:
        phone_logger.critical("No Music On Hold (moh) section in config file. Exiting")
okhin's avatar
okhin committed
157
        phone_logger.exception(e)
158 159 160
        raise e
    try:
        result = bridge.create()
okhin's avatar
okhin committed
161
        result = bridge.startMoh(config['moh']['class'])
162 163 164 165 166 167 168 169 170 171 172
    except Exception as e:
        phone_logger.error("Cannot start 'on hold' bridge")
        phone_logger.exception(e)
        raise e

async def listen():
    '''
    Start listening on the websocket
    '''
    global running
    global ws
173 174
    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 = await websockets.connect(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(
okhin's avatar
okhin committed
175
        config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password']))
176
    ws_logger.debug('Websocket connected: {}'.format(type(ws)))
okhin's avatar
okhin committed
177
    await hold_bridge()
178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206
    while running == True:
        try:
            event = await ws.recv()
            # Let's call the applications function
            await dispatch(json.loads(event))
        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()

async def dispatch(self, event):
    """
    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, self.db)
    call.event_handler(event)

207
class Call(object):
okhin's avatar
okhin committed
208
    """
209
    This Class is used to manage operations on a call, to print it and dump it.
okhin's avatar
okhin committed
210
    """
211
    history = []
212
    actions = {'Created': 'call_caller'
213
        , 'ChannelStateChange': 'change'
214 215
        , 'ChannelDtmfReceived': 'dtmf'
        , 'Hangup': 'hangup'}
216

217
    def __init__(self, caller, callee, owner, callid=None, db=None):
okhin's avatar
okhin committed
218 219 220 221
        try:
            self.caller = caller
            self.callee = callee
            self.owner = owner
222
            if callid == None:
223 224
                self.id = str(uuid.uuid4())
                self.db = db
225
                self.event_handler({'type': 'Created', 'timestamp': datetime.datetime.now().isoformat(), 'channel': {'id': 'Init'}})
226 227
            else:
                self.id = callid
okhin's avatar
okhin committed
228
        except Exception as e:
229
            phone_logger.exception(e)
okhin's avatar
okhin committed
230 231 232 233 234
            raise e

    def url(self):
        return ''.join(['/calls/', self.id])

235 236 237 238
    def state(self):
        sort = sorted(self.history, reverse=True, key=itemgetter(1))
        return sort[0][0]

okhin's avatar
okhin committed
239 240
    @classmethod
    def load(cls, callid, db):
241
        phone_logger.debug("Loading call {}".format(callid,))
okhin's avatar
okhin committed
242
        try:
243
            results = db.execute('SELECT caller, callee, owner, callid, history FROM calls WHERE callid = ?;', (callid,))
okhin's avatar
okhin committed
244
            result = results.fetchone()
245 246
            assert len(result) == 5
            object = cls(result[0], result[1], result[2], result[3])
okhin's avatar
okhin committed
247
            object.history = json.loads(result[4])
248
            object.db = db
okhin's avatar
okhin committed
249
            return object
250
        except Exception as e:
251
            phone_logger.exception(e)
252
            raise e
okhin's avatar
okhin committed
253

254
    def update(self, new_state):
255
        '''
256
        Let's update the state of the call. new_state is a tuple in the form (newstate, timestamp,)
257
        '''
258
        phone_logger.debug("Got a new state: {}".format(new_state,))
259 260
        self.history.append(new_state)
        self.save()
261 262 263 264 265 266

    def event_handler(self, event):
        '''
        There's a new event related to our call
        '''
        state = event['type']
267
        if state in self.actions:
268
            getattr(self, self.actions[state])(event=event)
269

270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292
    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.
        '''
        bridge_id = '-'.join(event['channel']['id'].split('-')[:-1])
        bridge = ari.Bridge(config['asterisk'], bridge_id, 'mixed')
        results = json.loads(bridge.status())
        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()

293 294 295 296 297
    def dtmf(self, event):
        '''
        We received a DTMF sequence
        '''
        try:
298
            assert self.state().startswith('Up')
299
            # The only thing we want to do is to call the callee if we press 1
300 301 302 303
            if event['digit'] != '1':
                return
            # Now, we're connectig the other side
            # But first we should stop the playback
okhin's avatar
okhin committed
304
            phone_logger.debug('Stopping the playback currently running')
305
            # We're stoping the playback, with the same ID as the channel, to keep track of it
okhin's avatar
okhin committed
306
            playback = ari.Playback(config['asterisk'], event['channel']['id'], 'sound:mario')
307
            playback.stop()
okhin's avatar
okhin committed
308
            # Now we're moving the channel to the MOH Bridge
okhin's avatar
okhin committed
309
            phone_logger.debug('Moving call {} to the garage'.format(event['channel']['id']))
okhin's avatar
okhin committed
310
            moh_bridge = ari.Bridge(config['asterisk'], config['moh']['name'])
311
            moh_bridge.addChannel(event['channel']['id'])
312
            # Next we need to originate a call to the other side
313
            phone_logger.info('Will now connect {} to {}'.format(self.caller, self.callee,))
314
            endpoint = 'SIP/' + sanitize_phonenumber(self.callee) + '@' + config['asterisk']['sip-context']
okhin's avatar
okhin committed
315
            channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.callee))
316
            channel.originate(endpoint)
317
        except AssertionError as e:
318
            logging.error("Received a DTMF sequence out le being in  a '{}' state, ignoring: {}".format(self.state(), event['digit']))
319

320 321 322 323
    def change(self, event):
        '''
        Let's change the state of the call
        '''
324
        self.update((':'.join([event['channel']['state'], event['channel']['id'].split('-')[-1]]), event['timestamp'],))
325
        phone_logger.info("New state for call {}: {}".format(event['channel']['id'], event['channel']['state']))
326 327
        # We now need to take action according to our new state
        if event['channel']['state'] == 'Up':
328 329
            # Are we the caller orthe callee?
            if event['channel']['id'].endswith(self.callee):
okhin's avatar
okhin committed
330
                # Step 1 create a bridge
okhin's avatar
okhin committed
331
                bridge = ari.Bridge(config['asterisk'], self.id, 'mixing')
332
                phone_logger.debug("Creating a bridges to connect {} to {}".format(self.caller, self.callee,))
333
                bridge.create()
okhin's avatar
okhin committed
334
                # Step 2, moving channels
335
                channels = ",".join([self.id + '-' + self.caller, self.id + '-' + self.callee])
okhin's avatar
okhin committed
336
                phone_logger.debug("Moving channels to the created bridge: {}".format(brodge.name,))
337
                bridge.addChannel(channels)
338
                phone_logger.info("Call now fully connected: {} <-> {}".format(self.caller, self.callee))
339
                return
340 341
            # Call is being picked up, we want to play a song
            try:
okhin's avatar
okhin committed
342
                channel = ari.Channel(config['asterisk'], event['channel']['id'])
okhin's avatar
okhin committed
343
                logging.debug('Preparing to create a playback')
344
                # We're starting a playback, with the same ID as the channel, to keep track of it
okhin's avatar
okhin committed
345
                channel.playback(channel.name, config['asterisk']['playback'])
346
            except Exception as e:
347
                logging.exception(e)
348
                raise e
349 350

    def call_caller(self, event):
351
        '''
okhin's avatar
okhin committed
352 353
        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.
354

okhin's avatar
okhin committed
355
        The bridge needed to connect the calls together will be created later.
356
        '''
357
        self.update((':'.join([event['type'], event['channel']['id'].split('-')[-1]]), event['timestamp'],))
okhin's avatar
okhin committed
358
        # We want to check if the moh bridge is opened
okhin's avatar
okhin committed
359
        moh_bridge = ari.Bridge(config['asterisk'], config['moh']['name'], 'holding')
360
        try:
361
            moh_bridge.status()
okhin's avatar
okhin committed
362 363 364
        except HTTPError as e:
            # The bridge dos not exist
            logging.error("The Hold bridges does not exists")
365 366 367
            raise e

        # Now, let's create the channel
368
        try:
okhin's avatar
okhin committed
369 370
            endpoint = 'SIP/' + sanitize_phonenumber(self.caller) + '@' + config['asterisk']['sip-context']
            channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.caller))
371
            channel.originate()
372
        except Exception as e:
373 374
            logging.exception(e)
            raise e
375 376

    def save(self):
377 378 379
        '''
        Save the Call to database.
        '''
380
        phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder)))
381 382 383 384 385 386
        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:
387
            phone_logger.exception(e)
388
            raise e
389

390 391 392
def start():
    global running
    running = True
393
    threads.submit(app.run, server='paste')
394 395
    loop.run_until_complete(listen())

396 397
def stop():
    global running
398
    running = False
399 400 401
    ws.close()
    loop.close()
    threads.shutdown(wait=False)
402
    sys.exit(0)
okhin's avatar
okhin committed
403

404
@app.get('/calls/<callid>')
405
@app.get('/calls/')
406
@authenticated
407
@connected
408
def calls(db, callid=None):
okhin's avatar
okhin committed
409 410 411 412
    """
    Return the list of calls associated to the connected user.
    The call has a status, caller, callee and history (status change+timestamp)
    """
413
    bottle_logger.debug("GET {}".format(request.fullpath))
414 415 416 417 418 419
    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)
420
                bottle_logger.debug("Call fetched: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
421 422 423 424
                calls.append(call)
            head = {'call': request.fullpath, 'user': request.params['api'], 'hits': len(calls)}
            return {'head': head, 'data': calls}
        except Exception as e:
425
            bottle_logger.exception(e)
426 427 428 429 430 431
            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()
432
        bottle_logger.debug("Found {} results: {}".format(len(rows), rows))
433 434 435 436 437
        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:
438 439
        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']))
440 441
        abort(403, "You do not have the authorization to get this call")
    except Exception as e:
442 443
        bottle_logger.debug("Exception catched: {}".format(e,))
        bottle_logger.error("Call not found {}".format(callid,))
444 445 446 447
        abort(404, "This call does not exist")

@app.post('/calls/')
@app.post('/calls/<callid>')
448
@authenticated
449
@connected
450
def originate(db, callid=None):
451
    bottle_logger.debug("POST {}".format(request.fullpath))
452 453 454 455 456
    try:
        if callid is not None:
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], callid=request.params['callid'], db=db)
        else:
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], db=db)
457
        bottle_logger.debug("Originate a call: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
458
        head = {'call': call.url()
459 460
            , 'user': request.params['api']
            , 'hits': 1}
461 462
        return {'header': head, 'data': call}
    except Exception as e:
463 464
        bottle_logger.debug("Missing params : {}".format([p for p in request.params],))
        bottle_logger.exception(e)
465
        abort(400, "Missing or incorrect fields, the call cannot be processed")
466 467

if __name__ == '__main__':
468 469
    db = sqlite3.connect(config['piphone']['db'])
    phone_logger.info("Starting the piphone SIP backend")
470 471
    try:
        start()
472
    except (KeyboardInterrupt, SystemExit):
473
        stop()