piphone.py 19.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
okhin's avatar
okhin committed
19
from bottle import request, abort, Bottle, JSONPlugin, template, static_file, auth_basic
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
@asyncio.coroutine
okhin's avatar
okhin committed
151
def listen(db):
152 153 154 155 156
    '''
    Start listening on the websocket
    '''
    global running
    global ws
157
    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
158
    ws = yield from websockets.connect(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(
okhin's avatar
okhin committed
159
        config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password']))
160 161 162
    ws_logger.debug('Websocket connected: {}'.format(type(ws)))
    while running == True:
        try:
okhin's avatar
okhin committed
163
            event = yield from ws.recv()
164
            # Let's call the applications function
okhin's avatar
okhin committed
165
            yield from dispatch(json.loads(event), db)
166 167 168 169 170 171 172 173 174 175 176
        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
177
@asyncio.coroutine
okhin's avatar
okhin committed
178
def dispatch(event, db):
179 180 181 182 183 184 185 186 187
    """
    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
188
    call = Call.load(call_id, db)
189 190
    call.event_handler(event)

191
class Call(object):
okhin's avatar
okhin committed
192
    """
193
    This Class is used to manage operations on a call, to print it and dump it.
okhin's avatar
okhin committed
194
    """
195
    history = []
196
    actions = {'Created': 'call_caller'
197
        , 'ChannelStateChange': 'change'
198
        , 'ChannelDtmfReceived': 'dtmf'
199
        , 'ChannelDestroyed': 'hangup'
200
        , 'ChannelHangupRequest': 'hangup'}
201

202
    def __init__(self, caller, callee, owner, callid=None, db=None):
okhin's avatar
okhin committed
203 204 205 206
        try:
            self.caller = caller
            self.callee = callee
            self.owner = owner
207
            if callid == None:
208
                self.id = str(uuid.uuid4())
209 210
            else:
                self.id = callid
okhin's avatar
okhin committed
211
            self.db = db
okhin's avatar
okhin committed
212
        except Exception as e:
213
            phone_logger.exception(e)
okhin's avatar
okhin committed
214 215
            raise e

okhin's avatar
okhin committed
216
    @property
okhin's avatar
okhin committed
217 218 219
    def url(self):
        return ''.join(['/calls/', self.id])

okhin's avatar
okhin committed
220
    @property
221 222 223 224
    def state(self):
        sort = sorted(self.history, reverse=True, key=itemgetter(1))
        return sort[0][0]

okhin's avatar
okhin committed
225 226
    @classmethod
    def load(cls, callid, db):
okhin's avatar
okhin committed
227
        phone_logger.debug("Loading call {} from db {}".format(callid, db,))
okhin's avatar
okhin committed
228
        try:
229
            results = db.execute('SELECT caller, callee, owner, callid, history FROM calls WHERE callid = ?;', (callid,))
okhin's avatar
okhin committed
230
            result = results.fetchone()
231
            assert len(result) == 5
okhin's avatar
okhin committed
232
            object = cls(result[0], result[1], result[2], result[3], db=db)
okhin's avatar
okhin committed
233 234
            object.history = json.loads(result[4])
            return object
235
        except Exception as e:
236
            phone_logger.exception(e)
237
            raise e
okhin's avatar
okhin committed
238

239
    def update(self, new_state):
240
        '''
241
        Let's update the state of the call. new_state is a tuple in the form (newstate, timestamp,)
242
        '''
243
        phone_logger.debug("Got a new state: {}".format(new_state,))
244 245
        self.history.append(new_state)
        self.save()
246 247 248 249 250 251

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

255 256 257 258 259 260 261 262
    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.
263
        We might also be in a case where no channel has been created … or we're still on moh.
264 265
        '''
        bridge_id = '-'.join(event['channel']['id'].split('-')[:-1])
266 267 268
        self.update((':'.join([event['channel']['state']
            , event['channel']['id'].split('-')[-1]])
            , event['timestamp']))
269 270
        try:
            bridge = ari.Bridge(config['asterisk'], bridge_id, 'mixed')
271
            results = json.loads(bridge.status())
272 273 274 275 276 277
        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

278 279 280 281 282 283 284 285 286 287 288
        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()

289 290 291 292 293
    def dtmf(self, event):
        '''
        We received a DTMF sequence
        '''
        try:
okhin's avatar
okhin committed
294
            assert self.state.startswith('Up')
295
            # The only thing we want to do is to call the callee if we press 1
296 297 298
            if event['digit'] != '1':
                return
            # Now, we're connectig the other side
299
            # We need to originate a call to the other side
300
            phone_logger.info('Will now connect {} to {}'.format(self.caller, self.callee,))
301
            endpoint = 'SIP/' + sanitize_phonenumber(self.callee) + '@' + config['asterisk']['sip-context']
okhin's avatar
okhin committed
302
            channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.callee))
303
            channel.originate(endpoint)
304
        except AssertionError as e:
okhin's avatar
okhin committed
305
            phone_logger.error("Received a DTMF sequence out le being in  a '{}' state, ignoring: {}".format(self.state, event['digit']))
306

307 308 309 310
    def change(self, event):
        '''
        Let's change the state of the call
        '''
okhin's avatar
okhin committed
311
        # First we need to check if it's really a change ie, if the new state is not the previous one
312
        self.update((':'.join([event['channel']['state']
okhin's avatar
okhin committed
313
            , event['channel']['id'].split('-')[-1]])
314
            , event['timestamp'],))
315
        phone_logger.info("New state for call {}: {}".format(event['channel']['id'], event['channel']['state']))
316 317
        # We now need to take action according to our new state
        if event['channel']['state'] == 'Up':
318
            # Are we the caller or the callee?
319
            if event['channel']['id'].endswith(sanitize_phonenumber(self.callee)):
320
                # We are the callee
okhin's avatar
okhin committed
321
                # Step 1 create a bridge
okhin's avatar
okhin committed
322
                bridge = ari.Bridge(config['asterisk'], self.id, 'mixing')
323
                phone_logger.debug("Creating a bridges to connect {} to {}".format(self.caller, self.callee,))
324 325 326 327
                try:
                    bridge.create()
                except Exception as e:
                    raise e
328 329 330
                # Step 2, stop playing moh to the channel
                channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.caller))
                channel.stopMoh()
331
                # Step 3, connect everyone
okhin's avatar
okhin committed
332
                channels = ",".join([self.id + '-' + sanitize_phonenumber(self.caller), event['channel']['id']])
333
                phone_logger.debug("Moving channels ({}) to the created bridge: {}".format(channels, bridge.name,))
334
                bridge.addChannel(channels)
335
                phone_logger.info("Call now fully connected: {} <-> {}".format(self.caller, self.callee))
336
                return
337 338
            # Call is being picked up, we want to play a song
            try:
okhin's avatar
okhin committed
339
                channel = ari.Channel(config['asterisk'], event['channel']['id'])
340
                channel.startMoh(config['moh']['class'])
341
            except Exception as e:
342
                phone_logger.exception(e)
343
                raise e
344 345

    def call_caller(self, event):
346
        '''
okhin's avatar
okhin committed
347 348
        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.
349

okhin's avatar
okhin committed
350
        The bridge needed to connect the calls together will be created later.
351
        '''
352
        self.update((':'.join([event['type']
okhin's avatar
okhin committed
353
            , event['channel']['id'].split('-')[-1]])
354
            , event['timestamp'],))
355
        # Now, let's create the channel
356
        try:
okhin's avatar
okhin committed
357 358
            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
359
            channel.originate(endpoint)
360
        except Exception as e:
361
            phone_logger.exception(e)
362
            raise e
363 364

    def save(self):
365 366 367
        '''
        Save the Call to database.
        '''
368
        phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder)))
369 370 371 372 373 374
        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
375
            bottle_logger.exception(e)
376
            phone_logger.exception(e)
377
            raise e
378

okhin's avatar
okhin committed
379
def start(db):
380 381
    global running
    running = True
382
    threads.submit(app.run, server='paste')
okhin's avatar
okhin committed
383
    loop.run_until_complete(listen(db))
384

385 386
def stop():
    global running
387
    running = False
388 389 390
    ws.close()
    loop.close()
    threads.shutdown(wait=False)
391
    sys.exit(0)
okhin's avatar
okhin committed
392

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

@app.post('/calls/')
@app.post('/calls/<callid>')
437
@authenticated
438
@connected
439
def originate(db, callid=None):
440
    bottle_logger.debug("POST {}".format(request.fullpath))
441 442
    try:
        if callid is not None:
okhin's avatar
okhin committed
443
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], callid=callid, db=db)
444 445
        else:
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], db=db)
446
        bottle_logger.debug("Originate a call: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
447
        call.event_handler({'type': 'Created', 'timestamp': datetime.datetime.now().isoformat(), 'channel': {'id': 'Init'}})
okhin's avatar
okhin committed
448 449
        call.save()
        head = {'call': call.url
450 451
            , 'user': request.params['api']
            , 'hits': 1}
452 453
        return {'header': head, 'data': call}
    except Exception as e:
454 455
        bottle_logger.debug("Missing params : {}".format([p for p in request.params],))
        bottle_logger.exception(e)
456
        abort(400, "Missing or incorrect fields, the call cannot be processed")
457

458 459 460 461 462 463 464 465
@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')

okhin's avatar
okhin committed
466 467 468 469 470 471 472 473 474 475 476 477 478 479
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


480
@app.get('/admin')
okhin's avatar
okhin committed
481
@auth_basic(login_admin)
482
def little_admin(db):
483
    # We need to check if we're admin
484
    users = db.execute('SELECT api, token, admin FROM users').fetchall()
485
    return template('index', users=users, token=request.params['token'])
486 487

@app.post('/admin')
okhin's avatar
okhin committed
488
@auth_basic(login_admin)
489
def medium_admin(db):
490
    api = request.forms.get('api')
491
    token = request.forms.get('api_token')
492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508
    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)


509
if __name__ == '__main__':
510 511
    db = sqlite3.connect(config['piphone']['db'])
    phone_logger.info("Starting the piphone SIP backend")
512
    try:
okhin's avatar
okhin committed
513
        start(db)
514
    except (KeyboardInterrupt, SystemExit):
515
        stop()