-
Notifications
You must be signed in to change notification settings - Fork 1
/
controllershowisk.py
471 lines (409 loc) · 21.9 KB
/
controllershowisk.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
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
134
135
136
137
138
139
140
141
142
143
144
145
146
147
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
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
#! /usr/bin/python
'''
Showisk (asterisk enabled interactive show) enables someone to use asterisk (open-source PBX)
to place multiple calls to people (aka actors) and playback synchronized audio files, while
allowing for choices to be made by the actors. One can create complicated audio plans with arbitrary
many sync points, and decision trees of arbitrary depth.
It relies on pyst and specififally the Python interface to the Asterisk Manager Interface (AMI)
Author: Thanassis Boulis, (aka Athanassios Boulis), August 2013
'''
import asterisk.manager
import sys
from datetime import datetime
from time import sleep, time
from random import shuffle
import threading
class Show:
'''
The class that does all the work by starting up the various threads and connection to the AMI.
When creating a new object from this class, it initialises several variables and connects to
the AMI. Then, by calling begin(), you start the show.
'''
def __init__(self, names, audioPlan, audiencePhone=None, server='127.0.0.1', username='admin', pswd=''):
'''
The constructor for this class needs the names of the actors in a list of strings, the special
phone numbers (that give the signal to start the show) again as a list of strings, and the
audio plan which should be list of dictionaries.
'''
self.names = names
self.audiencePhone = audiencePhone
self.audioPlan = audioPlan
self.triggerPhones = []
self.collectedPhones = []
# configuration variables for common sounds. The audio dir is added ONLY to the filenames
# in the audio plan, NOT the common sounds variables below.
self.audiodir = '' # a full path to be added to the files in the audio plan
self.press1 = 'press-1' # the press 1 sound
self.beep = 'beep' # the beep sound, after a button pressed
self.tryAgain = 'please-try-again' # the please try again sound
self.whenReconnected = None # audio to play when reconnecting after hangup
self.nothuman = None # an optional sound
self.thankyou = 'auth-thankyou' # saying thank you after establishing a call
self.register ='/audio/Register1'
self.register2 = '/audio/Register2' # asked when calling in
self.registerconf = '/audio/RegisterConf'
self.registerfail = '/audio/RegisterFail'
self.triggerPreshow = 'welcome' # to be played at the trigger phone just before begin()
self.triggerDuringShow = 'auth-thankyou' # to be played at the trigger phone during begin()
self.pathToTrunk = 'SIP/didlogic-trunk/' # where do we place outgoing calls
self.defaultOption = 1 # the option returned when no option is given by the user
# you can have any key (e.g., 0,1,2,'default') just make
# sure this key is always included in the audio plan
# whenever the user has options to take
# Dictionaries to hold important info on calls.
self.channel = {} # Referenced with actor name as key
self.uniqueID = {} # Referenced with actor name as key
self.phoneNum = {} # Referenced with actor name as key
self.actor = {} # Referenced with uniqueID as key
self.actorFromChan = {} # Referenced with channel as key
# events to signify when calls are answered, DTMF tones received, and ending of audiofile
# playback. These are dictionaries because there are multiple events, one for each actor.
self.eventsCallAnswer = {}
self.eventsDTMF = {}
self.eventsPlayEnd = {}
# event to signify trigger phone to stop the collectPhones function
self.eventTrigger = threading.Event()
# a dictionary to hold the last DTMF pressed, one entry for each actor
self.pressedDTMF = {}
self.shuttingDown = False # flag to inform handle_Hangup() not to try to reconnect
# the AMI manager to interact with Asterisk
self.manager = asterisk.manager.Manager()
# connect to the manager
try:
self.manager.connect(server)
self.manager.login(username, pswd)
# register some callbacks
self.manager.register_event('Shutdown', self.handle_shutdown) # shutdown
self.manager.register_event('NewCallerid', self.handle_NewCallerID) # the NewCallerid events help us find out the channel and unique id of our call
self.manager.register_event('AGIExec', self.handle_AGIExec) # to know the end of playback
self.manager.register_event('DTMF', self.handle_DTMF) # detected pressed keys
self.manager.register_event('Hangup', self.handle_Hangup) # react to hang ups
#self.manager.register_event('*', self.handle_event) # catch all, for debug purposes
except asterisk.manager.ManagerSocketException, (errno, reason):
print "Error connecting to the manager: %s" % reason
self.manager.close()
sys.exit(1)
except asterisk.manager.ManagerAuthException, reason:
print "Error logging in to the manager: %s" % reason
self.manager.close()
sys.exit(1)
except asterisk.manager.ManagerException, reason:
print "Error: %s" % reason
self.manager.close()
sys.exit(1)
def collectPhones(self,triggerPhones=None, delay=None):
'''
A function to invoke preshow, in order to collect valid phone numbers. Stopped by a special
phone(s) calling in and triggering the stop, or a timeout. Most of the work is done in the
_testPhone() function invoked as multiple threads in handle_Newstate()
'''
# register an event to process phones calling in
self.manager.register_event('Newstate', self.handle_Newstate)
if triggerPhones is None and delay is None:
delay = 300 # wait for 5 mins and then exit
else:
self.triggerPhones = triggerPhones
print datetime.now(), 'Waiting for incoming calls to be registered, max delay:', delay, 'secs. List of trigger phones', self.triggerPhones
self.eventTrigger.wait(delay)
# check the reason for stopping to wait
if self.eventTrigger.is_set():
reason = 'trigger call'
else:
reason = 'timeout'
print datetime.now(), '***** Collecting phones finished. Reason:', reason, '. List of collected phones', self.collectedPhones
# no need to have this event registered anymore
self.manager.unregister_event('Newstate', self.handle_Newstate)
def begin(self, phones=None, randomShuffle=True):
'''
The main function to start the show. It first tries to originate the calls and then begins
to execute the plan period by period.
'''
# the Newexten event help us know when an phone is answered. Used AFTER collectPhones()
self.manager.register_event('Newexten', self.handle_NewExten)
# if no explicit phone list is given, then use the list that collectPhoneNums() hopefully collected
if phones is None: phones = self.collectedPhones
# shuffle the order if needed
if randomShuffle: shuffle(phones)
print datetime.now(), 'Phones will be linked to actors in this way:', zip(phones, self.names)
# First try to originate enough calls, and establish the connection is with a person
actorThreads = []
for phone, actorName in zip(phones, self.names):
# the arguments
t = threading.Thread(target=self._establishCall, args=(phone, actorName))
t.start()
actorThreads.append(t)
# if an audience phone defined then establish a call for the special actor 'Audience'
# no need to press 1 to establish the call, so the third argument is False
if self.audiencePhone:
t = threading.Thread(target=self._establishCall, args=(self.audiencePhone, 'Audience', False, 60))
t.start()
actorThreads.append(t)
# wait for all threads to finish before proceeding
for t in actorThreads:
t.join()
# TO DO: check if we have enough succesfull calls, and if not try to establish more
#give some time for all the messages to arrive
sleep(1)
# Then execute the plan period by period, synchronising the calls before every new period start
for period in self.audioPlan:
# reset actor Thread to empty
actorThreads = []
for actorName in period:
if actorName in self.channel:
# create a thread to handle this period for this actor. _execute_plan() does all
# the work and takes two arguments: the plan for this actor and the actorName
t = threading.Thread(target=self._execute_plan, args=(period[actorName], actorName))
t.start()
actorThreads.append(t)
else:
print datetime.now(), "WARNING", actorName, "is in the plan but does not have an established call"
# wait for all threads to finish before proceeding to the next period
for t in actorThreads:
t.join()
# remember to clean up
self.shuttingDown = True
for actorName in self.channel:
self.manager.hangup(self.channel[actorName])
self.manager.close()
def _establishCall(self, phone, actorName, press1needed=True, delay=30, reconnected=False):
'''
Multiple threads of this function are started in begin(). Originates a call and then waits
for 1 to be pressed. If not pressed within <delay> secs, it hangs up the call.
'''
# originate calls asynchronously so that multiple calls can be initiated in parallel.
# Otherwise a call has to be answered for another one to start ringing, even if
# the originate commands are given from different threads
response = self.manager.originate(self.pathToTrunk + phone, caller_id=actorName, async=True, exten='callwait', context='testcall', priority='1')
print datetime.now(), 'Originating call to', actorName, phone, 'Response:', response
# wait for the call to be answered
self.eventsCallAnswer[actorName] = threading.Event()
self.eventsCallAnswer[actorName].wait(delay)
if self.eventsCallAnswer[actorName].is_set():
# if we do not need to press 1 print a success message and return.
if not press1needed:
print datetime.now(), 'Success establishing call to', actorName
self.phoneNum[actorName] = phone # associate phone number with actor
self.playback(self.thankyou, actorName, dir='')
return True
sleep(1) # needs a small delay before the channel becomes valid for playback
# if the call is answered, ask for the actor to press 1 (to confirm real interaction)
self.playback(self.press1, actorName, dir='')
if self.waitToPress1(actorName, delay=delay):
print datetime.now(), 'Success establishing call to', actorName
self.phoneNum[actorName] = phone # associate phone number with actor
self.playback(self.thankyou, actorName, dir='')
if reconnected: self.playback(self.whenReconnected, actorName, dir='')
else:
if self.nothuman:
self.playback(self.nothuman, actorName, dir='')
self.manager.hangup(self.channel[actorName])
print datetime.now(), 'Call answered but', actorName, 'did not press 1 within', delay, 'secs'
print datetime.now(), '======================WARNING! PERFORMANCE MUST BE RESTARTED, Please press CTRL Z and run python debuggrantShow.py==========================='
# remove this actor, channel, and unique ID from the corresponding dictionaries
chan = self.channel[actorName]
uniqID = self.uniqueID[actorName]
del self.channel[actorName]
del self.uniqueID[actorName]
del self.actor[uniqID]
del self.actorFromChan[chan]
else:
print datetime.now(), 'Call to', actorName, 'was NOT answered'
def playback(self, filename, actorName, dir=None, waitToEnd=True):
'''
Plays back an audio file to the channel associated with <actorName>.
Sends the proper AGI command, and then *waits* till it is notified that the playback has ended
You can also call it so it does not wait, or with a different audio dir
'''
if dir is None: dir = self.audiodir
cdict = {'Action':'AGI'}
cdict['Channel'] = self.channel[actorName]
cdict['Command'] = 'EXEC Playback ' + dir + filename
cdict['CommandID'] = 'MyCommandID'
response = self.manager.send_action(cdict)
print datetime.now(), "Playing audio file", filename, "to", actorName, ". Start response:", response
if response.headers['Response'] == 'Success' and waitToEnd:
# Wait for the playback to finish. Create a new event to wait upon. The new event is
# initially clear and we are waiting for the handle_AGIExec() method to set it.
self.eventsPlayEnd[actorName] = threading.Event()
self.eventsPlayEnd[actorName].wait()
print datetime.now(), "Playing audio file", filename, "to", actorName, ". Finished"
def waitForDTMF(self, actorName, plan, delay=8, defaultReturn=None):
'''
Waits for a valid key pressed (DTMF tone) up to <delay> secs.
Valid options are taken from the keys of the given <plan>. If not valid option is given
within <delay> secs, the default options <defaultReturn> is returned
'''
if defaultReturn is None: defaultReturn = self.defaultOption
start = end = time()
while end - start < delay:
self.eventsDTMF[actorName] = threading.Event()
waitDuration = delay - (end-start)
#print datetime.now(), "waiting for DTMF", actorName, plan, waitDuration
# block here waiting
self.eventsDTMF[actorName].wait(waitDuration)
# when done, check whether the event was set, or expired
if self.eventsDTMF[actorName].is_set():
# check whether the pressed key is a valid option in our plan
if self.pressedDTMF[actorName] in plan:
return self.pressedDTMF[actorName]
else:
# a key was pressed but is not valid, ask again
self.playback(self.tryAgain, actorName, dir='')
else:
# the wait period just expired, no valid key was pressed
return defaultReturn
# update
end = time()
return defaultReturn
def waitToPress1(self, actorName, delay=30):
return self.waitForDTMF(actorName, {1:None}, delay, defaultReturn=0)
def _testPhone(self, phone, channel, uniqueID):
'''
Called in a thread by handle_Newstate(), this function tests whether an incoming call
is answered by a human. If so, it stores the number in self.collectedPhones
'''
print datetime.now(), 'Received call from number:', phone, '. Testing suitability'
sleep(0.5)
# first update the dictionaries, using the phone as the actorName. This way we can use the
# same waitForDTMF() and playback() as with the outgoing calls.
self.channel[phone] = channel
self.actor[uniqueID] = phone
self.actorFromChan[channel] = phone
sleep(0.5)
#while 1, if input[actorName] = 1, do this - if 2, do this etc.
self.fakething(phone, channel, uniqueID)
self.playback(self.register, phone, dir='')
if not self.waitToPress1(phone): return
self.playback(self.register2, phone, dir='')
if not self.waitToPress1(phone): return
# we have established that this phone number is suitable, add it to the list if not there
# The rest are functions that we register with the pyst manager to handle AMI events
def handle_NewCallerID(self, event, manager):
actorName = event.headers['CallerIDName']
self.channel[actorName] = event.headers['Channel']
self.uniqueID[actorName] = event.headers['Uniqueid']
self.actor[event.headers['Uniqueid']] = actorName
self.actorFromChan[event.headers['Channel']] = actorName
print datetime.now(), "Registering", actorName, "with channel", self.channel[actorName], "and call uniqueID", self.uniqueID[actorName]
def handle_NewExten(self, event, manager):
if event.headers['Application'] == 'Answer':
actorName = self.actor[event.headers['Uniqueid']]
print datetime.now(), "Call Answered by", actorName
if actorName in self.eventsCallAnswer and not self.eventsCallAnswer[actorName].is_set():
self.eventsCallAnswer[actorName].set()
else:
print datetime.now(), "WARNING", actorName, "answered a call, which is not originated, or waiting to be answered"
def handle_DTMF(self, event, manager):
#print datetime.now(), event.name, event.headers
if event.headers['Begin'] == 'Yes' and event.headers['Direction'] == 'Received':
actorName = self.actor[event.headers['Uniqueid']]
# store the pressed digit, so that other threads can find it
self.pressedDTMF[actorName] = int(event.headers['Digit'])
# notify the thread waiting for this by setting/trigering the right event
# if the event is not there, or is already set, then a thread is not waiting for it
if actorName in self.eventsDTMF and not self.eventsDTMF[actorName].is_set():
# play the beep sound. Decided it's not needed
# self.playback(self.beep, actorName, dir='', waitToEnd=False)
self.eventsDTMF[actorName].set()
print datetime.now(), actorName, "pressed key", event.headers['Digit']
else:
print datetime.now(), actorName, "pressed key", event.headers['Digit'], 'IGNORED'
def handle_AGIExec(self, event, manager):
# we are only issuing exec playback commands so if we receive an End Subevent
# it means that the playback is over.
if event.headers['SubEvent'] == 'End':
actorName = self.actorFromChan[event.headers['Channel']]
# notify the thread waiting for this by setting/trigering the right event
if actorName in self.eventsPlayEnd and not self.eventsPlayEnd[actorName].is_set():
self.eventsPlayEnd[actorName].set()
else:
print datetime.now(), "WARNING: playback ended for", actorName, "WITHOUT a thread waiting for it"
def handle_shutdown(self, event, manager):
print "Received shutdown event"
manager.close()
def handle_Hangup(self, event, manager):
actorName = event.headers['CallerIDName']
uniqID = event.headers['Uniqueid']
# Try to reconect only if we are not shutting down and this is an established call
if (not self.shuttingDown) and (actorName in self.phoneNum) and (actorName in self.uniqueID) and (self.uniqueID[actorName] == uniqID):
print datetime.now(), actorName, '*Hangup* Will try to call back.'
# delete relevant entries from the dictionaries, keep only the phoneNum connection
chan = self.channel[actorName]
del self.channel[actorName]
del self.uniqueID[actorName]
del self.actor[uniqID]
del self.actorFromChan[chan]
# then set any events that the existing thread might be waiting on
if actorName in self.eventsDTMF:
self.eventsDTMF[actorName].set()
if actorName in self.eventsPlayEnd:
self.eventsPlayEnd[actorName].set()
# we could wait for the thread to join (i.e., exit) but there is no need.
sleep(0.5)
# establish a new call. Start a new thread, we should not do any waiting in handlers
# we are still waiting for 1 to be pressed, 30sec max delay, *and* playing the whenReconnected sound
t = threading.Thread(target=self._establishCall, args=(self.phoneNum[actorName], actorName, True, 30, True))
t.start()
def handle_Newstate(self, event, manager):
cid = event.headers['CallerIDNum']
uniqID = event.headers['Uniqueid']
chan = event.headers['Channel']
# the call has been asnwered. It has to be an incoming call this handler is unregistered
# before we run begin() which makes outgoing calls
if event.headers['ChannelStateDesc'] == 'Up':
# is it a valid number?
try:
val = int(cid)
except ValueError:
print datetime.now(), 'Call in:', cid, 'is not a valid phone number to keep'
return
# start a new thread to handle this call
t = threading.Thread(target=self._testPhone, args=(cid, chan, uniqID))
t.start()
def handle_event(self, event, manager):
# This is a catch-all handler for debugging. However, we can safely ignore some events.
if (event.name == 'RTCPReceived') or (event.name == 'RTCPSent'):
return
print datetime.now(), "Received event: %s" % event.name
print event.headers
# The class defines all we need. We can import this file in our own scripts and create audioplans
# and shows. We can also run this file on its own, and the following test code will run
if __name__ == "__main__":
# names of the main characters, to make description of the plan and reporting easier
names = ['Actor1','Actor2','Actor3','Actor4','Actor5','Actor6']
'''
The audio plan is structured as follows: It is a list of periods. A period is the sync checkpoint
for all actors. All actors should complete their path of actions before starting the next period.
A period is a list of trees, one for each actor (technically is a dict/map of trees)
A tree is a tree of actions and possible options that an Actor has at a given period. An action
is an audio file to be played. A tree is represented as a dictionary (of possibly nested
dictionaries). Generally in Python, a dictionary, has multiple entries of the form key:value
separated with commas. In our tree representation, if a key has non-numeric value then it is
interpreted as an audio filename to be played. The value of the key is the next action to take.
'None' means that nothing happens and we are done. The value can also be another tree! If the key
values are numeric, then the program interprets this as a choice that needs to be made by the user
by pressing a number in the phone keypad (DTMF tones). Once the choice is made and is valid, then
the value of the corresponding key is the action. It can be a simple filename (in which case the
corresponding filename is played and we are done) or it can be another tree. This way we can
build arbitrarily complex trees of actions and options. When creating options it is a good idea for
the preceding audio file to explain these options and ask you to take them. For example if we have
this tree {'blah':{1:'hi', 2:'bye'}} it will play the 'blah' and then just wait for a DTMF tone.
So the valid options should be given and explained at the end of audio file 'blah'
If the DTMF tone pressed is not one of the options given, it will 'say please try again'
The program will try to get a correct input for x secs (x=30 by default). Giving an invalid
input will not reset this timer. If no valid option is given, a default option will be taken by
the program. Currenly th default option is 1, but you one can easily change this when calling
the function waitForDTMF
'''
# create a new show
show = Show(names, audioPlan, audiencePhone=None, username='admin', pswd='L1v3pupp3t5')
# you can set several config parameters such as sound files. Look at the beginning of the class
# definition to find all the configuration parameters as class attributes
show.whenReconnected = 'hello-world'
# define your trigger phone numbers in a list, run collectPhones(), with optional maximum delay
# in secs, and then just begin the show
triggerPhones = ['61413817002']
show.collectPhones(triggerPhones, delay=150)
show.begin()
# if you do not want to collect them during preshow then do not call collectPhone() and pass
# a list of phones as an arg to begin() e.g. show.begin(['302101000000', '61413000000'])