piphone.py 20.5 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
from bottle import request, abort, Bottle, JSONPlugin, template, static_file
20
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

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')
okhin's avatar
okhin committed
37
        elif os.path.isfile('/etc/piphone/sip_config.ini'):
38
            logging.debug("Let's use /etc/iphone/config.ini as a config file")
okhin's avatar
okhin committed
39
            config.read('/etc/piphone/sip_config.ini')
40
        else:
okhin's avatar
okhin committed
41
            raise Exception("No configuration file found (tried ./config.ini and /etc/piphone/sip_config.ini")
42 43 44 45
    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
app.install(JSONPlugin(json_dumps=lambda s: json.dumps(s, cls=PiphoneJSONEncoder)))

50
threads = concurrent.futures.ThreadPoolExecutor(max_workers=5)
51
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

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
okhin's avatar
okhin committed
78
                , 'url': obj.url
79 80 81
                , '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
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:
okhin's avatar
okhin committed
117
            bottle_logger.exception(e)
118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134
            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
135 136 137 138 139 140 141 142 143 144 145 146 147 148
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

okhin's avatar
okhin committed
149 150
@asyncio.coroutine
def hold_bridge():
151 152 153 154 155
    '''
    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
156
        bridge = ari.Bridge(config['asterisk'], config['moh']['name'], 'holding')
157 158
    except Exception as e:
        phone_logger.critical("No Music On Hold (moh) section in config file. Exiting")
okhin's avatar
okhin committed
159
        phone_logger.exception(e)
160 161 162
        raise e
    try:
        result = bridge.create()
okhin's avatar
okhin committed
163
        result = bridge.startMoh(config['moh']['class'])
164 165 166 167 168
    except Exception as e:
        phone_logger.error("Cannot start 'on hold' bridge")
        phone_logger.exception(e)
        raise e

okhin's avatar
okhin committed
169
@asyncio.coroutine
okhin's avatar
okhin committed
170
def listen(db):
171 172 173 174 175
    '''
    Start listening on the websocket
    '''
    global running
    global ws
176
    ws_logger.debug('Connecting to websocket: {}'.format(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password'])))
okhin's avatar
okhin committed
177
    ws = yield from websockets.connect(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(
okhin's avatar
okhin committed
178
        config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password']))
179
    ws_logger.debug('Websocket connected: {}'.format(type(ws)))
okhin's avatar
okhin committed
180
    yield from hold_bridge()
181 182
    while running == True:
        try:
okhin's avatar
okhin committed
183
            event = yield from ws.recv()
184
            # Let's call the applications function
okhin's avatar
okhin committed
185
            yield from dispatch(json.loads(event), db)
186 187 188 189 190 191 192 193 194 195 196
        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()

okhin's avatar
okhin committed
197
@asyncio.coroutine
okhin's avatar
okhin committed
198
def dispatch(event, db):
199 200 201 202 203 204 205 206 207
    """
    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'])
okhin's avatar
okhin committed
208
    call = Call.load(call_id, db)
209 210
    call.event_handler(event)

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

221
    def __init__(self, caller, callee, owner, callid=None, db=None):
okhin's avatar
okhin committed
222 223 224 225
        try:
            self.caller = caller
            self.callee = callee
            self.owner = owner
226
            if callid == None:
227
                self.id = str(uuid.uuid4())
228 229
            else:
                self.id = callid
okhin's avatar
okhin committed
230
            self.db = db
okhin's avatar
okhin committed
231
        except Exception as e:
232
            phone_logger.exception(e)
okhin's avatar
okhin committed
233 234
            raise e

okhin's avatar
okhin committed
235
    @property
okhin's avatar
okhin committed
236 237 238
    def url(self):
        return ''.join(['/calls/', self.id])

okhin's avatar
okhin committed
239
    @property
240 241 242 243
    def state(self):
        sort = sorted(self.history, reverse=True, key=itemgetter(1))
        return sort[0][0]

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

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

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

274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296
    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()

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

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

    def call_caller(self, event):
356
        '''
okhin's avatar
okhin committed
357 358
        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.
359

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

        # Now, let's create the channel
373
        try:
okhin's avatar
okhin committed
374 375
            endpoint = 'SIP/' + sanitize_phonenumber(self.caller) + '@' + config['asterisk']['sip-context']
            channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.caller))
okhin's avatar
okhin committed
376
            channel.originate(endpoint)
377
        except Exception as e:
378
            phone_logger.exception(e)
379
            raise e
380 381

    def save(self):
382 383 384
        '''
        Save the Call to database.
        '''
385
        phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder)))
386 387 388 389 390 391
        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:
okhin's avatar
okhin committed
392
            bottle_logger.exception(e)
393
            phone_logger.exception(e)
394
            raise e
395

okhin's avatar
okhin committed
396
def start(db):
397 398
    global running
    running = True
399
    threads.submit(app.run, server='paste')
okhin's avatar
okhin committed
400
    loop.run_until_complete(listen(db))
401

402 403
def stop():
    global running
404
    running = False
405 406 407
    ws.close()
    loop.close()
    threads.shutdown(wait=False)
408
    sys.exit(0)
okhin's avatar
okhin committed
409

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

@app.post('/calls/')
@app.post('/calls/<callid>')
454
@authenticated
455
@connected
456
def originate(db, callid=None):
457
    bottle_logger.debug("POST {}".format(request.fullpath))
458 459
    try:
        if callid is not None:
okhin's avatar
okhin committed
460
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], callid=callid, db=db)
461 462
        else:
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], db=db)
463
        bottle_logger.debug("Originate a call: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
464
        call.event_handler({'type': 'Created', 'timestamp': datetime.datetime.now().isoformat(), 'channel': {'id': 'Init'}})
okhin's avatar
okhin committed
465 466
        call.save()
        head = {'call': call.url
467 468
            , 'user': request.params['api']
            , 'hits': 1}
469 470
        return {'header': head, 'data': call}
    except Exception as e:
471 472
        bottle_logger.debug("Missing params : {}".format([p for p in request.params],))
        bottle_logger.exception(e)
473
        abort(400, "Missing or incorrect fields, the call cannot be processed")
474

475 476 477 478 479 480 481 482 483
@app.get('/static/<filepath:path>')
def static_files(filepath):
    """
    take care of static files.
    should use apache/nginx instead.
    """
    return static_file(filepath, root='./views')

@app.get('/admin')
484
@authenticated
485
def little_admin(db):
486 487 488 489 490
    # We need to check if we're admin
    requests = db.execute('SELECT admin FROM users WHERE api = ?', (request.params['api']));
    admin = admin.fetchall()
    if admin[0] is not True:
        abort(403, "You need to have an admin access")
491 492 493 494
    users = db.execute('SELECT api, token, admin FROM users').fetchall()
    return template('index', users=users)

@app.post('/admin')
495
@authenticated
496
def medium_admin(db):
497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515
    api = request.forms.get('api')
    token = request.forms.get('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)


516
if __name__ == '__main__':
517 518
    db = sqlite3.connect(config['piphone']['db'])
    phone_logger.info("Starting the piphone SIP backend")
519
    try:
okhin's avatar
okhin committed
520
        start(db)
521
    except (KeyboardInterrupt, SystemExit):
522
        stop()