piphone.py 18.9 KB
Newer Older
okhin's avatar
okhin committed
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
okhin's avatar
okhin committed
15
from operator import itemgetter
okhin's avatar
okhin committed
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

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()
okhin's avatar
okhin committed
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)
okhin's avatar
okhin committed
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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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