piphone.py 21.4 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

arg_parser.add_argument('-c', '--config', help="Config file")
okhin's avatar
okhin committed
28
args = arg_parser.parse_args()
29
try:
30
    logging.debug("Let's use {} as a config file".format(args.config,))
okhin's avatar
okhin committed
31
    config.read(args.config)
32 33
except AttributeError as e:
    print(e)
34 35 36 37
    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
38
        elif os.path.isfile('/etc/piphone/sip_config.ini'):
39
            logging.debug("Let's use /etc/iphone/config.ini as a config file")
okhin's avatar
okhin committed
40
            config.read('/etc/piphone/sip_config.ini')
41
        else:
okhin's avatar
okhin committed
42
            raise Exception("No configuration file found (tried ./config.ini and /etc/piphone/sip_config.ini")
43 44 45 46
    except Exception as e:
        arg_parser.print_help()
        sys.exit(1)

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

51
threads = concurrent.futures.ThreadPoolExecutor(max_workers=5)
52
loop = asyncio.get_event_loop()
53

54 55 56 57
running = False
ws = None

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

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
79
                , 'url': obj.url
80 81 82
                , 'history': obj.history
                , 'owner': obj.owner }
        else:
83
            return json.JSONEncoder.default(self, obj)
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
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
118
            bottle_logger.exception(e)
119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135
            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
136 137 138 139 140 141 142 143 144 145 146 147 148 149
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
150 151
@asyncio.coroutine
def hold_bridge():
152 153 154 155 156
    '''
    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
157
        bridge = ari.Bridge(config['asterisk'], config['moh']['name'], 'holding')
158 159
    except Exception as e:
        phone_logger.critical("No Music On Hold (moh) section in config file. Exiting")
okhin's avatar
okhin committed
160
        phone_logger.exception(e)
161 162 163
        raise e
    try:
        result = bridge.create()
okhin's avatar
okhin committed
164
        result = bridge.startMoh(config['moh']['class'])
165 166 167 168 169
    except Exception as e:
        phone_logger.error("Cannot start 'on hold' bridge")
        phone_logger.exception(e)
        raise e

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

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

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

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

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

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

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

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

276 277 278 279 280 281 282 283
    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.
284
        We might also be in a case where no channel has been created … or we're still on moh.
285 286
        '''
        bridge_id = '-'.join(event['channel']['id'].split('-')[:-1])
287 288
        try:
            bridge = ari.Bridge(config['asterisk'], bridge_id, 'mixed')
289
            results = json.loads(bridge.status())
290 291 292 293 294
        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']))
            self.update((':'.join([event['channel']['state']
okhin's avatar
okhin committed
295
                , event['channel']['id'].split('-')[-1]])
296 297 298
                , event['timestamp']))
            return

299 300 301 302 303 304 305 306 307 308
        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()
okhin's avatar
okhin committed
309
        self.update((':'.join(['Down'
okhin's avatar
okhin committed
310 311
            , event['channel']['id'].split('-')[-1]])
            , event['timestamp']))
312

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

340 341 342 343
    def change(self, event):
        '''
        Let's change the state of the call
        '''
okhin's avatar
okhin committed
344
        # First we need to check if it's really a change ie, if the new state is not the previous one
345
        self.update((':'.join([event['channel']['state']
okhin's avatar
okhin committed
346
            , event['channel']['id'].split('-')[-1]])
347
            , event['timestamp'],))
348
        phone_logger.info("New state for call {}: {}".format(event['channel']['id'], event['channel']['state']))
349 350
        # We now need to take action according to our new state
        if event['channel']['state'] == 'Up':
351
            # Are we the caller or the callee?
352
            if event['channel']['id'].endswith(sanitize_phonenumber(self.callee)):
okhin's avatar
okhin committed
353
                # Step 1 create a bridge
okhin's avatar
okhin committed
354
                bridge = ari.Bridge(config['asterisk'], self.id, 'mixing')
355
                phone_logger.debug("Creating a bridges to connect {} to {}".format(self.caller, self.callee,))
356 357 358 359
                try:
                    bridge.create()
                except Exception as e:
                    raise e
okhin's avatar
okhin committed
360
                # Step 2, moving channels
okhin's avatar
okhin committed
361
                channels = ",".join([self.id + '-' + sanitize_phonenumber(self.caller), self.id + '-' + sanitize_phonenumber(self.callee)])
362
                phone_logger.debug("Moving channels to the created bridge: {}".format(bridge.name,))
363
                bridge.addChannel(channels)
364
                phone_logger.info("Call now fully connected: {} <-> {}".format(self.caller, self.callee))
365
                return
366 367
            # Call is being picked up, we want to play a song
            try:
okhin's avatar
okhin committed
368
                channel = ari.Channel(config['asterisk'], event['channel']['id'])
369
                phone_logger.debug('Preparing to create a playback')
370
                # We're starting a playback, with the same ID as the channel, to keep track of it
okhin's avatar
okhin committed
371
                channel.playback(channel.name, config['asterisk']['playback'])
372
            except Exception as e:
373
                phone_logger.exception(e)
374
                raise e
375 376

    def call_caller(self, event):
377
        '''
okhin's avatar
okhin committed
378 379
        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.
380

okhin's avatar
okhin committed
381
        The bridge needed to connect the calls together will be created later.
382
        '''
383
        self.update((':'.join([event['type']
okhin's avatar
okhin committed
384
            , event['channel']['id'].split('-')[-1]])
385
            , event['timestamp'],))
okhin's avatar
okhin committed
386
        # We want to check if the moh bridge is opened
okhin's avatar
okhin committed
387
        moh_bridge = ari.Bridge(config['asterisk'], config['moh']['name'], 'holding')
388
        try:
389
            moh_bridge.status()
okhin's avatar
okhin committed
390 391
        except HTTPError as e:
            # The bridge dos not exist
392
            phone_logger.error("The Hold bridges does not exists")
393 394 395
            raise e

        # Now, let's create the channel
396
        try:
okhin's avatar
okhin committed
397 398
            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
399
            channel.originate(endpoint)
400
        except Exception as e:
401
            phone_logger.exception(e)
402
            raise e
403 404

    def save(self):
405 406 407
        '''
        Save the Call to database.
        '''
408
        phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder)))
409 410 411 412 413 414
        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
415
            bottle_logger.exception(e)
416
            phone_logger.exception(e)
417
            raise e
418

okhin's avatar
okhin committed
419
def start(db):
420 421
    global running
    running = True
422
    threads.submit(app.run, server='paste')
okhin's avatar
okhin committed
423
    loop.run_until_complete(listen(db))
424

425 426
def stop():
    global running
427
    running = False
428 429 430
    ws.close()
    loop.close()
    threads.shutdown(wait=False)
431
    sys.exit(0)
okhin's avatar
okhin committed
432

433
@app.get('/calls/<callid>')
434
@app.get('/calls/')
435
@authenticated
436
@connected
437
def calls(db, callid=None):
okhin's avatar
okhin committed
438 439 440 441
    """
    Return the list of calls associated to the connected user.
    The call has a status, caller, callee and history (status change+timestamp)
    """
442
    bottle_logger.debug("GET {}".format(request.fullpath))
443 444 445 446 447 448
    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)
449
                bottle_logger.debug("Call fetched: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
450 451 452 453
                calls.append(call)
            head = {'call': request.fullpath, 'user': request.params['api'], 'hits': len(calls)}
            return {'head': head, 'data': calls}
        except Exception as e:
454
            bottle_logger.exception(e)
455 456 457 458 459 460
            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()
461
        bottle_logger.debug("Found {} results: {}".format(len(rows), rows))
462 463
        assert len(rows) == 1
        call = Call.load(callid, db)
okhin's avatar
okhin committed
464
        head = {'call': call.url, 'user': request.params['api'], 'hits': 1}
465 466
        return {'head': head, 'data': call}
    except AssertionError as e:
467 468
        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']))
469 470
        abort(403, "You do not have the authorization to get this call")
    except Exception as e:
471 472
        bottle_logger.debug("Exception catched: {}".format(e,))
        bottle_logger.error("Call not found {}".format(callid,))
473 474 475 476
        abort(404, "This call does not exist")

@app.post('/calls/')
@app.post('/calls/<callid>')
477
@authenticated
478
@connected
479
def originate(db, callid=None):
480
    bottle_logger.debug("POST {}".format(request.fullpath))
481 482
    try:
        if callid is not None:
okhin's avatar
okhin committed
483
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], callid=callid, db=db)
484 485
        else:
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], db=db)
486
        bottle_logger.debug("Originate a call: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
487
        call.event_handler({'type': 'Created', 'timestamp': datetime.datetime.now().isoformat(), 'channel': {'id': 'Init'}})
okhin's avatar
okhin committed
488 489
        call.save()
        head = {'call': call.url
490 491
            , 'user': request.params['api']
            , 'hits': 1}
492 493
        return {'header': head, 'data': call}
    except Exception as e:
494 495
        bottle_logger.debug("Missing params : {}".format([p for p in request.params],))
        bottle_logger.exception(e)
496
        abort(400, "Missing or incorrect fields, the call cannot be processed")
497

498 499 500 501 502 503 504 505 506
@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')
507
@authenticated
508
def little_admin(db):
509
    # We need to check if we're admin
okhin's avatar
okhin committed
510
    admin = db.execute('SELECT admin FROM users WHERE api = ?', (request.params['api'],));
511
    admin = admin.fetchone()
okhin's avatar
okhin committed
512
    if admin[0] != 1:
513
        abort(403, "You need to have an admin access")
514
    users = db.execute('SELECT api, token, admin FROM users').fetchall()
515
    return template('index', users=users, token=request.params['token'])
516 517

@app.post('/admin')
518
@authenticated
519
def medium_admin(db):
520
    api = request.forms.get('api')
521
    token = request.forms.get('api_token')
522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538
    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)


539
if __name__ == '__main__':
540 541
    db = sqlite3.connect(config['piphone']['db'])
    phone_logger.info("Starting the piphone SIP backend")
542
    try:
okhin's avatar
okhin committed
543
        start(db)
544
    except (KeyboardInterrupt, SystemExit):
545
        stop()