piphone.py 17.6 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
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
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.
    '''
    try:
okhin's avatar
okhin committed
154
        bridge = ari.Bridge(config['asterisk'], config['moh']['name'], 'holding')
155
156
    except Exception as e:
        phone_logger.critical("No Music On Hold (moh) section in config file. Exiting")
okhin's avatar
okhin committed
157
        phone_logger.exception(e)
158
159
160
        raise e
    try:
        result = bridge.create()
okhin's avatar
okhin committed
161
        result = bridge.startMoh(config['moh']['class'])
162
163
164
165
166
167
168
169
170
171
172
    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
173
174
    ws_logger.debug('Connecting to websocket: {}'.format(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password'])))
    ws = await websockets.connect(config['webservice']['base_url'] + '?app={}&api_key={}:{}'.format(
okhin's avatar
okhin committed
175
        config['asterisk']['app'], config['asterisk']['key'], config['asterisk']['password']))
176
    ws_logger.debug('Websocket connected: {}'.format(type(ws)))
okhin's avatar
okhin committed
177
    await hold_bridge()
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
206
    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)

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

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

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

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

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

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

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

okhin's avatar
okhin committed
269
270
271
272
273
    def dtmf(self, event):
        '''
        We received a DTMF sequence
        '''
        try:
274
            assert self.state().startswith('Up')
okhin's avatar
okhin committed
275
            # The only thing we want to do is to call the callee if we press 1
276
277
278
279
            if event['digit'] != '1':
                return
            # Now, we're connectig the other side
            # But first we should stop the playback
okhin's avatar
okhin committed
280
            phone_logger.debug('Stopping the playback currently running')
281
            # We're stoping the playback, with the same ID as the channel, to keep track of it
okhin's avatar
okhin committed
282
            playback = ari.Playback(config['asterisk'], event['channel']['id'], 'sound:mario')
283
            playback.stop()
okhin's avatar
okhin committed
284
            # Now we're moving the channel to the MOH Bridge
okhin's avatar
okhin committed
285
            phone_logger.debug('Moving call {} to the garage'.format(event['channel']['id']))
okhin's avatar
okhin committed
286
            moh_bridge = ari.Bridge(config['asterisk'], config['moh']['name'])
287
            moh_bridge.addChannel(event['channel']['id'])
288
            # Next we need to originate a call to the other side
289
            phone_logger.info('Will now connect {} to {}'.format(self.caller, self.callee,))
290
            endpoint = 'SIP/' + sanitize_phonenumber(self.callee) + '@' + config['asterisk']['sip-context']
okhin's avatar
okhin committed
291
            channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.callee))
292
            channel.originate(endpoint)
okhin's avatar
okhin committed
293
        except AssertionError as e:
294
            logging.error("Received a DTMF sequence out le being in  a '{}' state, ignoring: {}".format(self.state(), event['digit']))
okhin's avatar
okhin committed
295

296
297
298
299
    def change(self, event):
        '''
        Let's change the state of the call
        '''
300
        self.update((':'.join([event['channel']['state'], event['channel']['id'].split('-')[-1]]), event['timestamp'],))
301
        phone_logger.info("New state for call {}: {}".format(event['channel']['id'], event['channel']['state']))
okhin's avatar
okhin committed
302
303
        # We now need to take action according to our new state
        if event['channel']['state'] == 'Up':
304
305
            # Are we the caller orthe callee?
            if event['channel']['id'].endswith(self.callee):
okhin's avatar
okhin committed
306
                # Step 1 create a bridge
okhin's avatar
okhin committed
307
                bridge = ari.Bridge(config['asterisk'], self.id, 'mixing')
308
                phone_logger.debug("Creating a bridges to connect {} to {}".format(self.caller, self.callee,))
309
                bridge.create()
okhin's avatar
okhin committed
310
                # Step 2, moving channels
311
                channels = ",".join([self.id + '-' + self.caller, self.id + '-' + self.callee])
okhin's avatar
okhin committed
312
                phone_logger.debug("Moving channels to the created bridge: {}".format(brodge.name,))
313
                bridge.addChannel(channels)
314
                phone_logger.info("Call now fully connected: {} <-> {}".format(self.caller, self.callee))
315
                return
okhin's avatar
okhin committed
316
317
            # Call is being picked up, we want to play a song
            try:
okhin's avatar
okhin committed
318
                channel = ari.Channel(config['asterisk'], event['channel']['id'])
okhin's avatar
okhin committed
319
                logging.debug('Preparing to create a playback')
okhin's avatar
okhin committed
320
                # We're starting a playback, with the same ID as the channel, to keep track of it
okhin's avatar
okhin committed
321
                channel.playback(channel.name, config['asterisk']['playback'])
okhin's avatar
okhin committed
322
            except Exception as e:
323
                logging.exception(e)
okhin's avatar
okhin committed
324
                raise e
325
326

    def call_caller(self, event):
327
        '''
okhin's avatar
okhin committed
328
329
        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.
330

okhin's avatar
okhin committed
331
        The bridge needed to connect the calls together will be created later.
332
        '''
333
        self.update((':'.join([event['type'], event['channel']['id'].split('-')[-1]]), event['timestamp'],))
okhin's avatar
okhin committed
334
        # We want to check if the moh bridge is opened
okhin's avatar
okhin committed
335
        moh_bridge = ari.Bridge(config['asterisk'], config['moh']['name'], 'holding')
336
        try:
337
            moh_bridge.status()
okhin's avatar
okhin committed
338
339
340
        except HTTPError as e:
            # The bridge dos not exist
            logging.error("The Hold bridges does not exists")
341
342
343
            raise e

        # Now, let's create the channel
344
        try:
okhin's avatar
okhin committed
345
346
            endpoint = 'SIP/' + sanitize_phonenumber(self.caller) + '@' + config['asterisk']['sip-context']
            channel = ari.Channel(config['asterisk'], self.id + '-' + sanitize_phonenumber(self.caller))
347
            channel.originate()
348
        except Exception as e:
349
350
            logging.exception(e)
            raise e
351
352

    def save(self):
353
354
355
        '''
        Save the Call to database.
        '''
356
        phone_logger.debug("Saving call {}: {}".format(self.id, json.dumps(self, cls=PiphoneJSONEncoder)))
357
358
359
360
361
362
        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:
363
            phone_logger.exception(e)
364
            raise e
365

366
367
368
369
370
371
def start():
    global running
    running = True
    threads.submit(app.run)
    loop.run_until_complete(listen())

372
373
def stop():
    global running
374
    running = False
375
376
377
    ws.close()
    loop.close()
    threads.shutdown(wait=False)
378
    sys.exit(0)
okhin's avatar
okhin committed
379

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

@app.post('/calls/')
@app.post('/calls/<callid>')
424
@authenticated
425
@connected
426
def originate(db, callid=None):
427
    bottle_logger.debug("POST {}".format(request.fullpath))
428
429
430
431
432
    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)
433
        bottle_logger.debug("Originate a call: {}".format(json.dumps(call, cls=PiphoneJSONEncoder)))
434
        head = {'call': call.url()
435
436
            , 'user': request.params['api']
            , 'hits': 1}
437
438
        return {'header': head, 'data': call}
    except Exception as e:
439
440
        bottle_logger.debug("Missing params : {}".format([p for p in request.params],))
        bottle_logger.exception(e)
441
        abort(400, "Missing or incorrect fields, the call cannot be processed")
442
443

if __name__ == '__main__':
444
445
    db = sqlite3.connect(config['piphone']['db'])
    phone_logger.info("Starting the piphone SIP backend")
446
447
    try:
        start()
448
    except (KeyboardInterrupt, SystemExit):
449
        stop()