piphone.py 20.5 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
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()
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

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)
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
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
219
        , 'ChannelDtmfReceived': 'dtmf'
        , 'Hangup': 'hangup'}
okhin's avatar
okhin committed
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
231
            self.db = db
            self.event_handler({'type': 'Created', 'timestamp': datetime.datetime.now().isoformat(), 'channel': {'id': 'Init'}})
okhin's avatar
okhin committed
232
        except Exception as e:
233
            phone_logger.exception(e)
okhin's avatar
okhin committed
234
235
            raise e

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

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

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

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

    def event_handler(self, event):
        '''
        There's a new event related to our call
        '''
        state = event['type']
okhin's avatar
okhin committed
272
273
274
275
276
277
278
        try:
            if self.state.startswith(state):
                # We're alreaydy in this state, we just need to noop
                return
        except IndexError:
            if state in self.actions:
                getattr(self, self.actions[state])(event=event)
279
        if state in self.actions:
280
            getattr(self, self.actions[state])(event=event)
281

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

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

    def call_caller(self, event):
364
        '''
okhin's avatar
okhin committed
365
366
        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.
367

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

        # Now, let's create the channel
381
        try:
okhin's avatar
okhin committed
382
383
            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
384
            channel.originate(endpoint)
385
        except Exception as e:
386
            phone_logger.exception(e)
387
            raise e
388
389

    def save(self):
390
391
392
        '''
        Save the Call to database.
        '''
393
        phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder)))
394
395
396
397
398
399
        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
400
            bottle_logger.exception(e)
401
            phone_logger.exception(e)
402
            raise e
403

okhin's avatar
okhin committed
404
def start(db):
405
406
    global running
    running = True
407
    threads.submit(app.run, server='paste')
okhin's avatar
okhin committed
408
    loop.run_until_complete(listen(db))
409

410
411
def stop():
    global running
412
    running = False
413
414
415
    ws.close()
    loop.close()
    threads.shutdown(wait=False)
416
    sys.exit(0)
okhin's avatar
okhin committed
417

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

@app.post('/calls/')
@app.post('/calls/<callid>')
462
@authenticated
463
@connected
464
def originate(db, callid=None):
465
    bottle_logger.debug("POST {}".format(request.fullpath))
466
467
    try:
        if callid is not None:
okhin's avatar
okhin committed
468
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], callid=callid, db=db)
469
470
        else:
            call = Call(request.params['caller'], request.params['callee'], request.params['api'], db=db)
471
        bottle_logger.debug("Originate a call: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
okhin's avatar
okhin committed
472
473
        call.save()
        head = {'call': call.url
474
475
            , 'user': request.params['api']
            , 'hits': 1}
476
477
        return {'header': head, 'data': call}
    except Exception as e:
478
479
        bottle_logger.debug("Missing params : {}".format([p for p in request.params],))
        bottle_logger.exception(e)
480
        abort(400, "Missing or incorrect fields, the call cannot be processed")
481

482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518

@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')
def little_admin():
    users = db.execute('SELECT api, token, admin FROM users').fetchall()
    return template('index', users=users)


@app.post('/admin')
def medium_admin():
    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)


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