app.py 19.1 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
19
import websockets
import requests
20
21
from bottle import request, abort, Bottle, JSONPlugin
from bottle_sqlite import SQLitePlugin
22
from daemonize import Daemonize
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
37
38
39
40
41
42
43
44
45

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')
        elif os.path.isfile('/etc/piphone/config.ini'):
            logging.debug("Let's use /etc/iphone/config.ini as a config file")
            config.read('/etc/piphone/config.ini')
        else:
            raise Exception("No configuration file found (tried ./config.ini and /etc/piphone/config.ini")
    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
50
51
app.install(JSONPlugin(json_dumps=lambda s: json.dumps(s, cls=PiphoneJSONEncoder)))

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

148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
async def hold_bridge():
    '''
    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.
    '''
    payload = {}
    payload['app'] = 'piphone'
    payload['api_key'] = 'piphone:passpiphone'
    payload['mode'] = 'holding'
    try:
        r = requests.post('http://185.34.33.12:8088/ari/bridges/piphone-moh', data=payload)
        r.raise_for_status()
        payload['mohClass'] = 'piphone'
        r = requests.post('http://185.34.33.12:8088/ari/bridges/piphone-moh/moh', data=payload)
        r.raise_for_status()
    except Exception as e:
        phone_logger.error("Cannot start 'on hold' bridge")
        phone_logger.exception(e)
        raise e

async def listen():
    '''
    Start listening on the websocket
    '''
    global running
    global ws
    ws = await websockets.connect('ws://185.34.33.12:8088/ari/events?app=piphone&api_key=piphone:passpiphone')
    ws_logger.debug('Websocket connected: {}'.format(type(ws)))
    await hold_bridges()
    while running == True:
        try:
            event = await ws.recv()
            # Let's call the applications function
            await dispatch(json.loads(event))
        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()

async def dispatch(self, event):
    """
    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)

206
class Call(object):
okhin's avatar
okhin committed
207
208
209
    """
    This Class is used to manage operatiosn on a call, to print it and dump it.
    """
210
    history = []
211
    actions = {'Created': 'call_caller'
212
213
        , 'ChannelStateChange': 'change'
        , 'ChannelDtmfReceived': 'dtmf'}
okhin's avatar
okhin committed
214

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

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

okhin's avatar
okhin committed
233
234
235
236
    def state(self):
        sort = sorted(self.history, reverse=True, key=itemgetter(1))
        return sort[0][0]

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

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

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

okhin's avatar
okhin committed
268
269
270
271
272
    def dtmf(self, event):
        '''
        We received a DTMF sequence
        '''
        try:
273
            assert self.state().startswith('Up')
okhin's avatar
okhin committed
274
            # The only thing we want to do is to call the callee if we press 1
275
276
277
278
279
280
281
282
283
            if event['digit'] != '1':
                return
            # Now, we're connectig the other side
            # But first we should stop the playback
            results = self.db.execute('SELECT login_ari, pass_ari FROM users WHERE api = ?', (self.owner,))
            login_ari, token_ari = results.fetchone()
            payload = {}
            payload['app'] = 'piphone'
            payload['api_key'] = ':'.join([login_ari, token_ari])
284
            phone_logger.debug('Stopping the playback currently running'.format(payload,))
285
286
            # We're stoping the playback, with the same ID as the channel, to keep track of it
            r = requests.delete('http://185.34.33.12:8088/ari/playbacks/{0}'.format(event['channel']['id']), data=payload)
okhin's avatar
okhin committed
287
288
            # Now we're moving the channel to the MOH Bridge
            payload['channel'] = event['channel']['id']
289
            phone_logger.debug('Moving call {} to the garage'.format(payload['channel']))
okhin's avatar
okhin committed
290
            r = requests.post('http://185.34.33.12:8088/ari/bridges/piphone-moh/addChannel', data=payload)
291
            # Next we need to originate a call to the other side
292
            phone_logger.info('Will now connect {} to {}'.format(self.caller, self.callee,))
293
            payload['endpoint'] = 'SIP/' + sanitize_phonenumber(self.callee) + '@forfait-kwaoo'
294
            phone_logger.debug('Preparing to send a request to the ARI with payload {}'.format(payload,))
295
            r = requests.post('http://185.34.33.12:8088/ari/channels/{}-{}'.format(self.id, sanitize_phonenumber(self.callee),), data=payload)
okhin's avatar
okhin committed
296
        except AssertionError as e:
297
            logging.error("Received a DTMF sequence out le being in  a '{}' state, ignoring: {}".format(self.state(), event['digit']))
okhin's avatar
okhin committed
298

299
300
301
302
    def change(self, event):
        '''
        Let's change the state of the call
        '''
303
        self.update((':'.join([event['channel']['state'], event['channel']['id'].split('-')[-1]]), event['timestamp'],))
304
        phone_logger.info("New state for call {}: {}".format(event['channel']['id'], event['channel']['state']))
okhin's avatar
okhin committed
305
        # We now need to take action according to our new state
okhin's avatar
okhin committed
306
307
        results = self.db.execute('SELECT login_ari, pass_ari FROM users WHERE api = ?', (self.owner,))
        login_ari, token_ari = results.fetchone()
okhin's avatar
okhin committed
308
        if event['channel']['state'] == 'Up':
309
310
            # Are we the caller orthe callee?
            if event['channel']['id'].endswith(self.callee):
okhin's avatar
okhin committed
311
312
313
314
315
                # Step 1 create a bridge
                payload = {}
                payload['app'] = 'piphone'
                payload['api_key'] = ':'.join([login_ari, token_ari])
                payload['type'] = 'mixing'
316
                phone_logger.debug("Creating a bridges to connect {} to {}".format(self.caller, self.callee,))
okhin's avatar
okhin committed
317
318
319
                r = requests.post('http://185.34.33.12:8088/ari/bridges/{}'.format(self.id), data=payload)
                # Step 2, moving channels
                payload['channel'] = ",".join([self.id + '-' + self.caller, self.id + '-' + self.callee])
320
                phone_logger.debug("Moving channels to the created bridge: {}".format(payload['channel']))
okhin's avatar
okhin committed
321
                r = requests.post('http://185.34.33.12:8088/ari/bridges/{}/addChannel'.format(self.id), data=payload)
322
                phone_logger.info("Call now fully connected: {} <-> {}".format(self.caller, self.callee))
323
                return
okhin's avatar
okhin committed
324
325
326
327
328
329
330
331
332
            # Call is being picked up, we want to play a song
            payload = {}
            try:
                payload['app'] = 'piphone'
                payload['api_key'] = ':'.join([login_ari, token_ari])
                payload['media'] = 'sound:mario'
                payload['lang'] = 'en_US'
                logging.debug('Preparing to send a request to the ARI with payload {}'.format(payload,))
                # We're starting a playback, with the same ID as the channel, to keep track of it
333
                r = requests.post('http://185.34.33.12:8088/ari/channels/{0}/play/{0}'.format(event['channel']['id']), data=payload)
okhin's avatar
okhin committed
334
335
                logging.debug("Now playing a sound on channel {}".format(self.id))
            except Exception as e:
336
                logging.exception(e)
okhin's avatar
okhin committed
337
                raise e
338
339

    def call_caller(self, event):
340
        '''
okhin's avatar
okhin committed
341
342
        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.
343

okhin's avatar
okhin committed
344
        The bridge needed to connect the calls together will be created later.
345
        '''
346
347
348
349
350
351
        self.update((':'.join([event['type'], event['channel']['id'].split('-')[-1]]), event['timestamp'],))
        results = self.db.execute('SELECT login_ari, pass_ari FROM users WHERE api = ?', (self.owner,))
        login_ari, token_ari = results.fetchone()
        payload = {}
        payload['app'] = 'piphone'
        payload['api_key'] = ':'.join([login_ari, token_ari])
okhin's avatar
okhin committed
352
        # We want to check if the moh bridge is opened
353
        try:
okhin's avatar
okhin committed
354
355
356
357
358
            r = requests.get('http://185.34.33.12:8088/ari/bridges/{}-moh'.format(payload['app']), data=payload)
            r.raise_for_status()
        except HTTPError as e:
            # The bridge dos not exist
            logging.error("The Hold bridges does not exists")
359
360
361
362
363
364
            raise e

        # Now, let's create the channel
        payload = {}
        payload['app'] = 'piphone'
        payload['api_key'] = ':'.join([login_ari, token_ari])
365
366
367
        try:
            payload['endpoint'] = 'SIP/' + sanitize_phonenumber(self.caller) + '@forfait-kwaoo'
            logging.debug('Preparing to send a request to the ARI with payload {}'.format(payload,))
368
            r = requests.post('http://185.34.33.12:8088/ari/channels/{}-{}'.format(self.id, sanitize_phonenumber(self.caller),), data=payload)
369
        except Exception as e:
370
371
            logging.exception(e)
            raise e
372
373

    def save(self):
374
375
376
        '''
        Save the Call to database.
        '''
377
        phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder)))
378
379
380
381
382
383
        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:
384
            phone_logger.exception(e)
385
            raise e
386

387
388
389
390
391
392
def start():
    global running
    running = True
    threads.submit(app.run)
    loop.run_until_complete(listen())

393
394
def stop():
    global running
395
    running = False
396
397
398
    ws.close()
    loop.close()
    threads.shutdown(wait=False)
399
    sys.exit(0)
okhin's avatar
okhin committed
400

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

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

if __name__ == '__main__':
465
466
    db = sqlite3.connect(config['piphone']['db'])
    phone_logger.info("Starting the piphone SIP backend")
467
468
    try:
        start()
469
470
    except:
        stop()